Flutter:仿京東專案實戰(2)-分類和商品列表頁面功能實現

語言: CN / TW / HK

在我個人認為學習一門新的語言(快速高效學習) 一定是通過實踐,最好的就是做專案,這裡我會簡單寫一個京東的Demo。

在上篇文章裡面建立了BottomNavigationBar,裡面包含了4個主介面,今天完成第二個主介面,分類頁面的功能和商品列表功能。

用到的知識點

1. 命名路由傳參

  • 路由表 routes裡面增加

'/product_list': (context, {arguments}) => ProductListPage(arguments: arguments), - 需要跳轉頁面的地方

Navigator.pushNamed(context, '/product_list', arguments: {'cid': _rightCateList[index].sId!});

  • 跳轉到的頁面

``` class ProductListPage extends StatefulWidget {

Map arguments;

ProductListPage({Key? key, required this.arguments}) : super(key: key);

@override _ProductListPageState createState() => _ProductListPageState(); } ```

2. 配置抓包

  • 引入這兩個dio 的標頭檔案 import 'package:dio/adapter.dart'; import 'package:dio/dio.dart';

  • 配置抓包程式碼

//設定只在debug模式下抓包 final kReleaseMode = false; final Dio dio = Dio(); if (!kReleaseMode){ //設定代理 抓包用 (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (HttpClient client) { client.findProxy = (uri) { return "PROXY localhost:8888"; }; }; } - 配置好後的抓包的效果:

截圖2021-12-23 上午9.05.59.png

3. 上拉載入下拉重新整理通過 flutter_easyrefresh 實現

  • 這裡是 flutter_easyrefresh 官網列出的幾種實現方式:

import 'package:flutter_easyrefresh/easy_refresh.dart'; .... // 方式一 EasyRefresh( child: ScrollView(), onRefresh: () async{ .... }, onLoad: () async { .... }, ) // 方式二 EasyRefresh.custom( slivers: <Widget>[], onRefresh: () async{ .... }, onLoad: () async { .... }, ) // 方式三 EasyRefresh.builder( builder: (context, physics, header, footer) { return CustomScrollView( physics: physics, slivers: <Widget>[ ... header, ... footer, ], ); } onRefresh: () async{ .... }, onLoad: () async { .... }, )

  • 在商品列表中的使用

EasyRefresh( child: ListView.builder( itemCount: productList.length, itemBuilder: (context, index) { //建立列表內容 return createContent(index); } ), //下拉重新整理 onRefresh: () async{ _page = 1; _getProductListData(false); }, //上拉載入 onLoad: () async { _page += 1; if(!_hasMore){ return; } _getProductListData(true); }, )

  • 更多的使用可以參考:https://github.com/xuelongqy/flutter_easyrefresh

4. 保持頁面狀態 AutomaticKeepAliveClientMixin

Flutter切換tabar後不會保留tabbar狀態 ,為了節約記憶體不會儲存widget的狀態,widget都是臨時變數。當我們使用TabBar,切換tabar,initState又會被呼叫一次。可以使用 AutomaticKeepAliveClientMixin 解決這個問題

  • 當前類要繼承 AutomaticKeepAliveClientMixin class _CategoryPageState extends State<CategoryPage> with AutomaticKeepAliveClientMixin

  • 實現這個方法 bool get wantKeepAlive =>true;

  • 新增 super.build(context) @override Widget build(BuildContext context) { super.build(context); return Container(); }

5. 資料和模型的轉換

這裡只是簡單的資料模型轉換,我採用手動的方式實現了

``` class ProductItemModel { String? sId; String? title;

ProductItemModel({this.sId, this.title,});

ProductItemModel.fromJson(Map json) { sId = json['_id']; title = json['title']; }

Map toJson() { final Map data = new Map(); data['_id'] = this.sId; data['title'] = this.title; return data; } } ```

6.ListView 的使用

截圖2021-12-20 下午9.33.30.png

7. GridView 網格佈局的實現

截圖2021-12-22 下午9.35.07.png

8. Image 常用方法

加入圖片的幾種方式: Image.asset:載入本地資源圖片 Image.network:載入網路資源圖片 Image.file:載入本地檔案中的圖片

截圖2021-12-23 上午11.16.21.png 截圖2021-12-23 上午11.16.30.png

9. 本地專案國際化

flutter_localizations: sdk: flutter

``` import 'package:flutter_localizations/flutter_localizations.dart';

new MaterialApp( localizationsDelegates: [ // ... app-specific localization delegate[s] here GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], supportedLocales: [ const Locale('en', 'US'), // English const Locale('he', 'IL'), // Hebrew // ... other locales the app supports ], // ... ) ```

具體支援國際化更多的方案可以參考:https://zhuanlan.zhihu.com/p/145992691

具體功能實現

實現的效果

截圖2021-12-22 下午9.51.30.png

全域性配置資訊類config.dart

例如可以在裡面存放域名

class Config{ static String domain="https://jdmall.itying.com/"; }

分類頁面的實現

整體頁面左邊通過ListView、右邊通過GridView實現,然後通過點選左邊列表實現右邊列表的資料重新整理。

定義資料模型

``` class CateModel { List result = [];

CateModel({required this.result});

CateModel.fromJson(Map json) { if (json['result'] != null) { json['result'].forEach((v) { result.add(new CateItemModel.fromJson(v)); }); } }

Map toJson() { final Map data = new Map(); if (this.result.length > 0) { data['result'] = this.result.map((v) => v.toJson()).toList(); } return data; } }

class CateItemModel { String? sId; //String? 表示可空型別 String? title; Object? status; String? pic; String? pid; String? sort;

CateItemModel( {this.sId, this.title, this.status, this.pic, this.pid, this.sort});

CateItemModel.fromJson(Map json) { sId = json['_id']; title = json['title']; status = json['status']; pic = json['pic']; pid = json['pid']; sort = json['sort']; }

Map toJson() { final Map data = new Map(); data['_id'] = this.sId; data['title'] = this.title; data['status'] = this.status; data['pic'] = this.pic; data['pid'] = this.pid; data['sort'] = this.sort; return data; } } ```

實現程式碼

``` class CategoryPage extends StatefulWidget { CategoryPage({Key? key}) : super(key: key);

_CategoryPageState createState() => _CategoryPageState(); }

class _CategoryPageState extends State with AutomaticKeepAliveClientMixin{

//當前選中 int _selectIndex=0; //左側列表資料 List _leftCateList=[]; //右側列表資料 List _rightCateList=[];

@override // TODO: implement wantKeepAlive 快取當前頁面 bool get wantKeepAlive =>true;

@override void initState() { // TODO: implement initState super.initState();

_getLeftCateData();

}

//左側分類的資料 _getLeftCateData() async{ var api = '${Config.domain}api/pcate'; var result = await Dio().get(api); var leftCateList = new CateModel.fromJson(result.data); setState(() { this._leftCateList = leftCateList.result; }); _getRightCateData(leftCateList.result[0].sId); }

//右側分類資料 _getRightCateData(pid) async{ var api = '${Config.domain}api/pcate?pid=${pid}'; var result = await Dio().get(api); var rightCateList = new CateModel.fromJson(result.data); setState(() { this._rightCateList = rightCateList.result; }); }

//左側列表佈局 Widget _leftCateWidget(leftWidth){ if(_leftCateList.length>0){ return Container(
width: leftWidth, height: double.infinity, // color: Colors.red, child: ListView.builder( itemCount: _leftCateList.length, itemBuilder: (context,index){ return Column( children: [ InkWell(
onTap: (){ setState(() { //重新整理右側列表的資料 _selectIndex= index; _getRightCateData(_leftCateList[index].sId); }); }, child: Container(
width: double.infinity, height: ScreenAdapter.height(84), padding: EdgeInsets.only(top:ScreenAdapter.height(24)), child: Text("${_leftCateList[index].title}",textAlign: TextAlign.center), color: _selectIndex==index? Color.fromRGBO(240, 246, 246, 0.9):Colors.white, ), ), Divider(height: 1), ], ); },

        ),
      );
  } else {
     return Container(         
        width: leftWidth,
        height: double.infinity
     );
  }

}

//建立右側列表 Widget _rightCateWidget(rightItemWidth,rightItemHeight){ if(_rightCateList.length>0){ return Expanded( flex: 1, child: Container( padding: EdgeInsets.all(10), height: double.infinity, color: Color.fromRGBO(240, 246, 246, 0.9), child: GridView.builder( gridDelegate:SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount:3, childAspectRatio: rightItemWidth/rightItemHeight, crossAxisSpacing: 10, mainAxisSpacing: 10 ), itemCount: _rightCateList.length, itemBuilder: (context,index){ //處理圖片 String pic = _rightCateList[index].pic; pic = Config.domain+pic.replaceAll('\', '/');

                  return InkWell(
                    onTap: (){
                      Navigator.pushNamed(context, '/product_list', arguments: {'cid': _rightCateList[index].sId!});
                    },
                    child: Container(
                      // padding: EdgeInsets.all(10),
                      child: Column(
                        children: <Widget>[
                          AspectRatio(
                            aspectRatio: 1/1,
                            child: Image.network("${pic}",fit: BoxFit.cover),
                          ),
                          Container(
                            height: ScreenAdapter.height(28),
                            child: Text("${_rightCateList[index].title}"),
                          )
                        ],
                      ),
                    ),
                  );
              },
            )
        ),
    );
} else {
    return Expanded(
        flex: 1,
        child: Container(
            padding: EdgeInsets.all(10),
            height: double.infinity,
            color: Color.fromRGBO(240, 246, 246, 0.9),
            child: Text("載入中..."),
        )
    );
}

}

@override Widget build(BuildContext context) { //左側寬度 var leftWidth=ScreenAdapter.getScreenWidth()/4; //右側每一項寬度=(總寬度-左側寬度-GridView外側元素左右的Padding值-GridView中間的間距/3 var rightItemWidth=(ScreenAdapter.getScreenWidth()-leftWidth-20-20)/3; //獲取計算後的寬度 rightItemWidth=ScreenAdapter.width(rightItemWidth); //獲取計算後的高度 var rightItemHeight=rightItemWidth+ScreenAdapter.height(28); return Scaffold( appBar: AppBar( title: Text('分類頁面'), ), body: Row( children: [ _leftCateWidget(leftWidth), _rightCateWidget(rightItemWidth,rightItemHeight) ], ), ); } } ```

實現效果

Simulator Screen Shot - iPhone 12 Pro - 2021-12-22 at 21.50.45.png

商品列表頁面

建立商品列表Model

``` class ProductModel { List result=[];

ProductModel({required this.result});

ProductModel.fromJson(Map json) { if (json['result'] != null) { json['result'].forEach((v) { result.add(new ProductItemModel.fromJson(v)); }); } }

Map toJson() { final Map data = new Map(); if (this.result != null) { data['result'] = this.result.map((v) => v.toJson()).toList(); } return data; } }

class ProductItemModel { String? sId; //String? 表示可空型別 String? title; String? cid; Object? price; //所有的型別都繼承 Object String? oldPrice; String? pic; String? sPic;

ProductItemModel( {this.sId, this.title, this.cid, this.price, this.oldPrice, this.pic, this.sPic});

ProductItemModel.fromJson(Map json) { sId = json['_id']; title = json['title']; cid = json['cid']; price = json['price']; oldPrice = json['old_price']; pic = json['pic']; sPic = json['s_pic']; }

Map toJson() { final Map data = new Map(); data['_id'] = this.sId; data['title'] = this.title; data['cid'] = this.cid; data['price'] = this.price; data['old_price'] = this.oldPrice; data['pic'] = this.pic; data['s_pic'] = this.sPic; return data; } } ```

實現程式碼

``` class ProductListPage extends StatefulWidget {

Map arguments;

ProductListPage({Key? key, required this.arguments}) : super(key: key);

@override _ProductListPageState createState() => _ProductListPageState(); }

class _ProductListPageState extends State {

final GlobalKey _scaffoldKey = new GlobalKey();

//當前頁碼 int _page = 1; //每次請求返回多少條資料 int _pageSize = 10; //排序 String _sort = ''; //是否還有更多 bool _hasMore = true; //每組ID int _selectHeaderId = 1; //頁面列表資料 List productList = []; //搜尋關鍵字 String _keyWords = ''; //文字輸入框的控制器 var _initKeywordsController = TextEditingController();

/二級導航資料/ List subHeaderList = [ {"id": 1, "title": "綜合", "fileds": "all", "sort": -1,}, //排序 升序:price_1 {price:1} 降序:price-1 {price:-1} {"id": 2, "title": "銷量", "fileds": 'salecount', "sort": -1}, {"id": 3, "title": "價格", "fileds": 'price', "sort": -1}, {"id": 4, "title": "篩選"} ];

@override void initState() { // TODO: implement initState super.initState();

_getProductListData(false);

}

//請求列表資料,非同步請求 _getProductListData(bool isMore) async {

var api;
if(_keyWords.isEmpty){
  api = '${Config.domain}api/plist?cid=${widget.arguments["cid"]}&page=${_page}&sort=${_sort}&pageSize=${_pageSize}';
} else {
  api = '${Config.domain}api/plist?cid=${widget.arguments["cid"]}&page=${_page}&sort=${_sort}&pageSize=${_pageSize}&search=${_keyWords}';
}

//設定只在debug模式下抓包
final kReleaseMode = false;
final Dio dio = Dio();
if (!kReleaseMode){
  //設定代理 抓包用
  (dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (HttpClient client) {
    client.findProxy = (uri) {
      return "PROXY localhost:8888";
    };
  };
}

var result = await dio.get(api);

//解析json資料,目前我都還是採用手動解析
var dataList = ProductModel.fromJson(result.data).result;
if(dataList.length > 10){
  _hasMore = true;
}

setState(() {
  if(isMore){
    productList.addAll(dataList);
  } else {
    productList = dataList;
  }
});

}

//改變分組的處理 subHeaderChange(id){ if(id==4){ setState(() { _selectHeaderId = id; _scaffoldKey.currentState!.openEndDrawer(); }); } else { setState(() { _selectHeaderId = id; _sort = "${_subHeaderList[id - 1]["fileds"]}${_subHeaderList[id - 1]["sort"]}"; _page = 1; productList = []; //改變sort排序 _subHeaderList[id - 1]['sort'] = _subHeaderList[id - 1]['sort'] * -1; _hasMore = true; _getProductListData(false); }); } }

//列表的內容 Widget createContent(index) { ProductItemModel itemModel = productList[index]; String pic = ''; if(itemModel.pic != null){ //Config存放全域性配置的類 //由於這個圖片連結有問題才這樣處理 pic = Config.domain + itemModel.pic!.replaceAll('\', '/'); } return Column( children: [ InkWell( onTap: (){ //push到下個頁面並傳參 Navigator.pushNamed(context, '/product_content', arguments: {'id' : itemModel.sId}); }, child: Row( children: [ Container( margin: EdgeInsets.only(left: 10), width: ScreenAdapter.width(180), height: ScreenAdapter.height(180), child: Image.network( pic, fit: BoxFit.cover, ), ), Expanded( flex: 1, child: Container( margin: EdgeInsets.all(10), height: ScreenAdapter.height(180), child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( itemModel.title!, maxLines: 2, overflow: TextOverflow.ellipsis, ), Row( children: [ Container( alignment: Alignment.center, height: ScreenAdapter.height(36), margin: EdgeInsets.only(right: 10), padding: EdgeInsets.fromLTRB(10, 0, 10, 0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Color.fromRGBO(230, 230, 230, 0.9), ), child: Text('4g'), ), Container( alignment: Alignment.center, height: ScreenAdapter.height(36), margin: EdgeInsets.only(right: 10), padding: EdgeInsets.fromLTRB(10, 0, 10, 0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: Color.fromRGBO(230, 230, 230, 0.9), ), child: Text( '126', ), ) ], ), Text( '¥${itemModel.price!.toString()}', style: TextStyle(color: Colors.red, fontSize: 16), ) ], ), ), ), ], ), ), ], ); }

//建立商品列表 Widget _productListWidget() { return Container( height: ScreenAdapter.getScreenHeight(), padding: EdgeInsets.all(10), margin: EdgeInsets.only(top: ScreenAdapter.height(80)), //配置重新整理 child: EasyRefresh( child: ListView.builder( itemCount: productList.length, itemBuilder: (context, index) { //建立列表內容 return createContent(index); } ), //下拉重新整理 onRefresh: () async{ _page = 1; _getProductListData(false); }, //上拉載入 onLoad: () async { _page += 1; if(!_hasMore){ return; } _getProductListData(true); }, ), ); }

//建立升降序的圖示 Widget _showIcon(id){ if(id==2 || id==3){ if(_subHeaderList[id-1]['sort'] == 1){ return Icon(Icons.arrow_drop_down); } else { return Icon(Icons.arrow_drop_up); } } return Text(''); }

//建立頭部分組 Widget _subHeaderWidget() { return Positioned( top: 0, width: ScreenAdapter.getScreenWidth(), height: ScreenAdapter.height(80), child: Container( //分組底部分割線 decoration: const BoxDecoration( border: Border( bottom: BorderSide( color: Color.fromRGBO(233, 233, 233, 0.9), width: 1))), child: Row( children: _subHeaderList.map((value){ return Expanded( flex: 1, child: InkWell( onTap: () { _subHeaderChange(value['id']); }, child: Padding( padding: EdgeInsets.fromLTRB( 0, ScreenAdapter.height(16), 0, ScreenAdapter.height(16)), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( child: Text( value['title'], textAlign: TextAlign.center, style: TextStyle(color: _selectHeaderId == value['id'] ? Colors.red : Colors.black), ), ), _showIcon(value['id']) ], ), ), )); }).toList(), ), ), ); }

@override Widget build(BuildContext context) { return Scaffold( key: _scaffoldKey, //建立導航欄 appBar: AppBar( leading: IconButton( onPressed: (){ Navigator.pop(context); }, icon: Icon(Icons.arrow_back), ), title: Container( //文字輸入 child: TextField( controller: this._initKeywordsController, autofocus: true, decoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.circular(30), borderSide: BorderSide.none ), ), onChanged: (value){ setState(() { //搜尋框輸入的文字 _keyWords = value; }); }, ), height: ScreenAdapter.height(68), decoration: BoxDecoration( color: Color.fromRGBO(233, 233, 233, 0.8), borderRadius: BorderRadius.circular(30) ), ), actions: [ InkWell( child: Container( width: ScreenAdapter.width(80), height: ScreenAdapter.height(68), child: Row( children: [ Text('搜尋', style: TextStyle(fontSize: 16),) ], ), ), onTap: (){ //點選搜尋框開始搜尋,這裡只是簡單的在綜合組搜尋 _subHeaderChange(1); }, ) ], ), endDrawer: Drawer( child: Container( child: Text('實現篩選功能'), ), ), body: !productList.isEmpty ? Stack( children: [ //建立導航欄下分頁欄 _subHeaderWidget(), //建立頁面 _productListWidget(), ], ) : Center( child: Text('沒有搜尋到商品'), ), ); } } ```

實現效果

Simulator Screen Shot - iPhone 12 Pro - 2021-12-23 at 09.14.59.png