Flutter:仿京東專案實戰(3)-商品詳情頁功能實現

語言: CN / TW / HK

highlight: a11y-dark

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

第一天 搭建專案框架,實現首頁的功能:https://juejin.cn/editor/drafts/7043351582573854727。

第二天實現 分類和商品列表頁面: https://juejin.cn/post/7044716539550892068。

Flutter-混合工程的持續整合實踐: https://juejin.cn/post/7042099001679675422

Dart2.15版本釋出了:https://mp.weixin.qq.com/s/g-1uCl3upI-JYHxUeEIbKg

前面兩篇文章分別完成了首頁和分類及商品列表頁面功能,這篇文章完成商品詳情頁的功能,這裡用到了以下知識點:

用到的知識點

1. Provider 狀態管理

什麼是Provider 狀態管理?

當我們想在多個頁面(元件/Widget)之間共享狀態(資料),或者一個頁面(組 件/Widget)中的多個子元件之間共享狀態(資料),這個時候我們就可以用 Flutter 中的狀態管理來管理統一的狀態(資料),實現不同元件直接的傳值和資料共享。provider 是 Flutter 官方團隊推出的狀態管理模式。

具體的使用:

  • 配置provider: ^6.0.1
  • 新建一個資料夾叫 provider,在 provider 資料夾裡面放我們對於的狀態管理類
  • 在 provider 裡面新建 counter.dart
  • counter.dart 裡面新建一個類繼承 minxins 的 ChangeNotifier 程式碼如下

```dart import 'package:provider/provider.dart'; class Counter with ChangeNotifier { int _count; Counter(this._count);

void add() { _count++; notifyListeners();//2 } get count => _count;//3 } ```

notifyListeners();這個方法是通知用到Counter物件的widget重新整理用的

  • 找到 main.dart 修改程式碼,新增 MultiProvider

dart class _MyAppState extends State<MyApp> { @override Widget build(BuildContext context) { return ScreenUtilInit( //配置設計稿的寬度高度 designSize: Size(750, 1334), builder:()=> MultiProvider( providers:[ ChangeNotifierProvider(create: (_) => Counter()), ], child: MaterialApp( localizationsDelegates: [ GlobalMaterialLocalizations.delegate, // 指定本地化的字串和一些其他的值 GlobalCupertinoLocalizations.delegate, // 對應的Cupertino風格 GlobalWidgetsLocalizations.delegate //指定預設的文字排列方向, 由左到右或由右到左 ], supportedLocales: [ Locale("en"), Locale("zh") ], initialRoute: '/', onGenerateRoute: onGenerateRoute))); } }

  • 獲取值、以及設定值

```dart import 'package:provider/provider.dart'; import '../../provider/Counter.dart';

Widget build(BuildContext context) { final counter = Provider.of(context); return Scaffold( floatingActionButton: FloatingActionButton( child: Icon(Icons.add), onPressed: (){ counter.add(); }, ), body: Text("counter 的值:${counter.count}") ); } ```

用Provider.of(context).count獲取_count的值,Provider.of(context)相當於Provider去查詢它管理的Counter(1);

用Provider.of(context).add();呼叫Counter()中的add()方法;

2. eventBus 廣播

  • 配置event_bus: ^2.0.0

  • 新建 event_bus.dart 類統一管理

```dart //引入 eventBus 包檔案 import 'package:event_bus/event_bus.dart';

//建立EventBus EventBus eventBus = new EventBus();

//event 監聽 class EventFn{ //想要接收的資料時什麼型別的,就定義相同型別的變數 dynamic obj; EventFn(this.obj); } ``` - 在需要廣播事件的頁面引入上面的 EventBus.dart 類 然後配置如下程式碼

dart eventBus.fire(new EventFn('資料'));

  • 在需要監聽廣播的地方引入上面的 event_bus.dart 類 然後配置如下程式碼

dart void initState() { super.initState(); //監聽廣播 eventBus.on<EventFn>().listen((event){ print(event); }); }

  • event_bus 取消事件監聽 dart @override void dispose() { super.dispose(); //取消訂閱 eventBusFn.cancel(); }

3. flutter_inappwebview 載入網頁

  • 配置flutter_inappwebview: ^5.3.2
  • 引入包檔案 import 'package:flutter_inappwebview/flutter_inappwebview.dart';
  • 初始化屬性 initialUrl: 被載入的初始URL。 initialOptions:將被載入的初始URL; initialOptions:將被使用的初始WebView選項。將要使用的初始WebView選項。 gestureRecognizers:指定哪些手勢應該被WebView消耗。 initialData:初始InAppWebViewInitial資料。將要載入的InAppWebViewInitialData的初始資料,比如一個HTML字串。 initialFile:將被載入的初始資產檔案。 initialHeaders: 將要使用的初始標頭檔案。將要使用的初始標頭檔案。 contextMenu:上下文選單,包含自定義選單項。上下文選單,包含自定義選單項。
  • 常用觸發的事件 onLoadStart:當WebView開始載入一個URL時被觸發的事件。 onLoadStop:當WebView完成載入一個URL時觸發的事件。 onLoadHttpError:當WebView主頁面收到一個HTTP錯誤時被觸發的事件。 onConsoleMessage:當WebView收到JavaScript控制檯訊息(如console.log ,console.error等)時觸發的事件。 shouldOverrideUrlLoading:噹噹前WebView中的URL即將被載入時,給主機應用程式一個控制的機會。 onDownloadStart:當WebView識別到一個可下載的檔案時發射的事件。 onReceivedHttpAuthRequest:當WebView接收到HTTP認證請求時觸發的事件。預設行為是取消該請求。 onReceivedServerTrustAuthRequest:當WebView需要執行伺服器信任認證(證書驗證)時被觸發的事件。 onPrint:當window.print()從JavaScript端被呼叫時被觸發的事件,預設行為是取消請求;onCreateWindow:當WebView需要進行伺服器信任驗證(證書驗證)時被觸發的事件。 onCreateWindow: 當InAppWebView請求主機應用程式建立一個新視窗時,例如當試圖開啟一個target="_blank"的連結或當window.open()被JavaScript端呼叫時,事件被觸發。

  • 簡單使用

dart Expanded( child: InAppWebView( initialUrlRequest: URLRequest(url: Uri.parse("https://jdmall.itying.com/pcontent?id=${_id}")), onProgressChanged: (InAppWebViewController controller, int progress){ if (progress / 100 > 0.9999) { setState(() { this._flag = false; }); } }, ) )

更多更詳細用法的可以參考這篇文章:https://juejin.cn/post/6869291513508659213

4. DefaultTabController和TabController

這兩個都可以實現頂部導航選項卡,區別就是TabController一般放在有狀態元件中使用,而DefaultTabController一般放在無狀態元件中使用,這裡沒有做成上下拉重新整理,在這個頁面用的是DefaultTabController。

TabController介紹

  • 常見的屬性

截圖2021-12-26 下午4.31.49.png

  • 常用方法介紹

截圖2021-12-26 下午4.32.08.png

TabBar屬性介紹

dart const TabBar({ Key key, @required this.tabs,//必須實現的,設定需要展示的tabs,最少需要兩個 this.controller, this.isScrollable = false,//是否需要滾動,true為需要 this.indicatorColor,//選中下劃線的顏色 this.indicatorWeight = 2.0,//選中下劃線的高度,值越大高度越高,預設為2 this.indicatorPadding = EdgeInsets.zero, this.indicator,//用於設定選中狀態下的展示樣式 this.indicatorSize,//選中下劃線的長度,label時跟文字內容長度一樣,tab時跟一個Tab的長度一樣 this.labelColor,//設定選中時的字型顏色,tabs裡面的字型樣式優先順序最高 this.labelStyle,//設定選中時的字型樣式,tabs裡面的字型樣式優先順序最高 this.labelPadding, this.unselectedLabelColor,//設定未選中時的字型顏色,tabs裡面的字型樣式優先順序最高 this.unselectedLabelStyle,//設定未選中時的字型樣式,tabs裡面的字型樣式優先順序最高 this.dragStartBehavior = DragStartBehavior.start, this.onTap,//點選事件 })

DefaultTabController的使用

dart return DefaultTabController( length: 3, child: Scaffold( appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: ScreenAdapter.width(400), child: TabBar( indicatorColor: Colors.red, indicatorSize: TabBarIndicatorSize.label, labelColor: Colors.red, unselectedLabelColor: Colors.white, tabs: [ Tab( child: Text('商品', style: TextStyle(fontSize: 18,),), ), Tab( child: Text('詳情', style: TextStyle(fontSize: 18),),), Tab( child: Text('評價', style: TextStyle(fontSize: 18),), ) ], ), ) ], ), ), body:Stack( children: [ TabBarView(children: [ ProductContentFirst(_productContentList), ProductContentSecond(_productContentList), ProductContentThrid(), ]), ], ), ))

showModalBottomSheet 底部面板

ModalBottomSheet底部面板,相當於彈出了一個新頁面,有點類似於 ActionSheet

ModalBottomSheet的屬性:

  • context:BuildContext
  • builder:WidgetBuilder
  • backgroundColor:背景色
  • elevation:陰影
  • shape:形狀
  • barrierColor:遮蓋背景顏色
  • isDismissible:點選遮蓋背景是否可消失
  • enableDrag:下滑消失

BoxDecoration 的使用說明

BoxDecoration通常用於給Widget元件設定邊框效果、陰影效果、漸變色等效果;常用屬性如下:

截圖2021-12-27 上午9.04.58.png

實現效果

Simulator Screen Shot - iPhone 12 Pro - 2021-12-26 at 16.48.57.png

Simulator Screen Shot - iPhone 12 Pro - 2021-12-26 at 16.49.07.png

具體實現程式碼

建立product_content_model.dart

```dart class ProductContentModel { late ProductContentitem result;

ProductContentModel({ required this.result, });

ProductContentModel.fromJson(Map json) { result = ProductContentitem.fromJson(json['result']); } Map toJson() { final _data = {}; _data['result'] = result.toJson(); return _data; } }

class ProductContentitem { //可為空的欄位就設定成可為空空 String? sId; String? title; String? cid; Object? price; Object? oldPrice; Object? isBest; Object? isHot; Object? isNew; late List attr; //不可為空 Object? status; late String pic; //不可為空 String? content; String? cname; int? salecount; String? subTitle; int count=1;

ProductContentitem({ this.sId, this.title, this.cid, this.price, this.oldPrice, this.isBest, this.isHot, this.isNew, required this.attr, this.status, required this.pic, this.content, this.cname, this.salecount, this.subTitle, });

ProductContentitem.fromJson(Map json) { sId = json['_id']; title = json['title']; cid = json['cid']; price = json['price']; oldPrice = json['old_price']; isBest = json['is_best']; isHot = json['is_hot']; isNew = json['is_new']; attr = List.from(json['attr']).map((e) => Attr.fromJson(e)).toList(); status = json['status']; pic = json['pic']; content = json['content']; cname = json['cname']; salecount = json['salecount']; subTitle = json['sub_title']; } Map toJson() { final _data = {}; _data['_id'] = sId; _data['title'] = title; _data['cid'] = cid; _data['price'] = price; _data['old_price'] = oldPrice; _data['is_best'] = isBest; _data['is_hot'] = isHot; _data['is_new'] = isNew; _data['attr'] = attr.map((e) => e.toJson()).toList(); _data['status'] = status; _data['pic'] = pic; _data['content'] = content; _data['cname'] = cname; _data['salecount'] = salecount; _data['sub_title'] = subTitle; return _data; } }

class Attr { late String cate; late List list;

Attr({ required this.cate, required this.list, });

Attr.fromJson(Map json) { cate = json['cate']; list = List.from(json['list']); }

Map toJson() { final _data = {}; _data['cate'] = cate; _data['list'] = list; return _data; } } ```

商品詳情頁的框架頁面

```dart class ProductContentPage extends StatefulWidget {

final Map arguments; ProductContentPage({Key? key, required this.arguments}) : super(key: key);

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

class _ProductContentPageState extends State {

List _productContentList=[];

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

_getContentData();

}

//請求商品資料 _getContentData() async{

var api ='${Config.domain}api/pcontent?id=${widget.arguments['id']}';

print(api);
var result = await Dio().get(api);
var productContent = new ProductContentModel.fromJson(result.data);

setState(() {
  _productContentList.add(productContent.result);
});

}

@override Widget build(BuildContext context) { //實現頂部導航選項卡 return DefaultTabController(length: 3, child: Scaffold( appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: ScreenAdapter.width(400), child: TabBar( indicatorColor: Colors.red, indicatorSize: TabBarIndicatorSize.label, labelColor: Colors.red, unselectedLabelColor: Colors.white, tabs: [ Tab( child: Text('商品', style: TextStyle(fontSize: 18,),), ), Tab( child: Text('詳情', style: TextStyle(fontSize: 18),), ), Tab( child: Text('評價', style: TextStyle(fontSize: 18),), ) ], ), ) ], ), actions: [ IconButton(onPressed: (){ //實現選單選項欄 showMenu(context: context, position: RelativeRect.fromLTRB(ScreenAdapter.width(600), ScreenUtil().statusBarHeight+40, 10, 0), items: [ PopupMenuItem( child: Row( children: [ Icon(Icons.home), SizedBox(width: 10,), Text('首頁') ], ), ), PopupMenuItem( child: Row( children: [ Icon(Icons.search), SizedBox(width: 10,), Text('搜尋') ], ), ) ] ); }, icon: Icon(Icons.more_horiz)), ], ), body: _productContentList.length > 0 ? Stack( children: [ TabBarView(children: [ //商品頁面 ProductContentFirst(_productContentList), //詳情頁面 ProductContentSecond(_productContentList), //評價頁面 ProductContentThrid(), ] ), //頁面底部的購物車、加入購物車、立即購買 Positioned( width: ScreenAdapter.width(750), height: ScreenAdapter.width(100)+ScreenAdapter.bottomBarHeight+10, bottom: 0, child: Container( decoration: BoxDecoration( border: Border( top: BorderSide( width: 1, color: Colors.black26 ) ), color: Colors.white ), child: Container( margin: EdgeInsets.only(top: 10, bottom: ScreenAdapter.bottomBarHeight), child: Row( children: [ Container( width: 100, height: ScreenAdapter.height(100), child: Column( children: [ Icon(Icons.shopping_cart, size: ScreenAdapter.width(38),), Text('購物車', style: TextStyle(fontSize:ScreenAdapter.size(24))), ], ), ), Expanded( flex: 1, child: CircleButton( color: Color.fromRGBO(253, 1, 0, 0.9), text: '加入購物車', callBack: (){ print('加入購物車'); }, ) ), Expanded( flex: 1, child: CircleButton( color: Color.fromRGBO(255, 165, 0, 0.9), text: '立即購買', callBack: (){ print('立即購買'); }, ) ) ], ), ), ), ), ], ) : LoadingWidget(), )); } } ```

商品詳情頁的商品頁面

```dart class ProductContentFirst extends StatefulWidget { final List _productContentList; ProductContentFirst(this._productContentList, {Key? key}) : super(key: key);

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

class _ProductContentFirstState extends State {

late ProductContentitem _productContent;

List _attr = [];

String _selectedValue='';

var cartProvider;

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

_productContent = widget._productContentList[0];
_attr = _productContent.attr;
_selectedValue = _attr.first.list.first;

}

//實現選項卡功能 _attrBottomSheet(){ showModalBottomSheet( context: context, builder: (context){ return Stack( children: [ Container( padding: EdgeInsets.only(left: 10), child: ListView( children: [ Column( mainAxisAlignment: MainAxisAlignment.center, children: _getAttrWidget(), ), Divider(), Container( margin: EdgeInsets.only(top: 10), height: ScreenAdapter.height(80), child: Row( children: [ Text("數量: ", style: TextStyle( fontWeight: FontWeight.bold)),

                    SizedBox(width: 10),
                    CartNum(this._productContent)
                  ],
                ),
              )
            ],
          ),
        ),
        Positioned(
          bottom: 0,
          width: ScreenAdapter.width(750),
          height: ScreenAdapter.height(76)+ScreenAdapter.bottomBarHeight,
          child: Container(
            color: Colors.white,
            padding: EdgeInsets.only(bottom: ScreenAdapter.bottomBarHeight),
            child: Row(
              children: <Widget>[
                Expanded(
                  flex: 1,
                  child: Container(
                    margin: EdgeInsets.fromLTRB(10, 0, 0, 0),
                    child: CircleButton(
                      color: Color.fromRGBO(253, 1, 0, 0.9),
                      text: "加入購物車",
                      callBack: () async {
                        print('豪傑是八點就把手');
                        await CartServices.addCart(this._productContent);
                        //關閉底部篩選屬性
                        Navigator.of(context).pop();
                        //呼叫Provider 更新資料
                        this.cartProvider.updateCartList();
                        Fluttertoast.showToast( msg: '加入購物車成功', toastLength: Toast.LENGTH_SHORT,gravity: ToastGravity.CENTER,);
                      },
                    ),
                  ),
                ),
                Expanded(
                  flex: 1,
                  child: Container(
                      margin: EdgeInsets.fromLTRB(10, 0, 10, 0),
                      child: CircleButton(
                        color: Color.fromRGBO(255, 165, 0, 0.9),
                        text: "立即購買",
                        callBack: () {
                          print('立即購買');
                        },
                      )),
                )
              ],
            ),
          ),
        )
      ],
    );
  }
);

}

List _getAttrWidget(){ List attrList = []; _attr.forEach((attrItem) { attrList.add( Wrap( children: [ Container( width: ScreenAdapter.width(100), child: Padding( padding: EdgeInsets.only(top: ScreenAdapter.height(28)), child: Text('${attrItem.cate}:', style: TextStyle(fontWeight: FontWeight.bold),textAlign: TextAlign.left), ), ), Container( width: ScreenAdapter.width(580), child: Wrap( children: _getAttrItemWidget(attrItem), ), ) ], ) ); });

return attrList;

}

List _getAttrItemWidget(attrItem) { List attrItemList = []; attrItem.list.forEach((item) { attrItemList.add(Container( margin: EdgeInsets.all(10), child: Chip( label: Text("${item}"), padding: EdgeInsets.all(10), ), )); }); return attrItemList; }

@override Widget build(BuildContext context) { this.cartProvider = Provider.of(context);

//處理圖片
String pic = Config.domain + this._productContent.pic;
pic = pic.replaceAll('\', '/');

//商品頁面內容
return Container(
  padding: EdgeInsets.all(10),
  child: ListView(
    children: [
      AspectRatio(
        aspectRatio: 16/9,
        child: Image.network(pic, fit: BoxFit.cover,),
      ),
      Container(
        padding: EdgeInsets.only(top: 10),
        child: Text(_productContent.title!,
          style: TextStyle(color: Colors.black87, fontSize: ScreenAdapter.size(36), fontWeight: FontWeight.bold),),
      ),
      Container(
          padding: EdgeInsets.only(top: 10),
          child: Text(
              _productContent.subTitle!,
              style: TextStyle(
                  color: Colors.black54,
                  fontSize: ScreenAdapter.size(28))
          )
      ),
      SizedBox(height: 10,),
      Container(
        child: Row(
          children: [
            Expanded(
                child: Row(
                  children: [
                    Text('特價:'),
                    Text('¥${_productContent.price}',style: TextStyle(
                        color: Colors.red,
                        fontSize: ScreenAdapter.size(46))),
                  ],
                )
            ),
            Expanded(
              flex: 1,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.end,
                children: [
                  Text('原價:'),
                  Text('¥${_productContent.oldPrice}',style: TextStyle(
                      color: Colors.black38,
                      fontSize: ScreenAdapter.size(28),
                      decoration: TextDecoration.lineThrough)),
                ],
              ),
            )
          ],
        ),
      ),
      //篩選
      _attr.length > 0
          ? Container(
        margin: EdgeInsets.only(top: 10),
        height: ScreenAdapter.height(80),
        child: InkWell(
          onTap: () {
            _attrBottomSheet();
          },
          child: Row(
            children: <Widget>[
              Text("已選: ",
                  style: TextStyle(fontWeight: FontWeight.bold)),
              Text("${_selectedValue}")
            ],
          ),
        ),
      )
          : Text(""),
      Divider(),
      Container(
        height: ScreenAdapter.height(80),
        child: Row(
          children: <Widget>[
            Text("運費: ", style: TextStyle(fontWeight: FontWeight.bold)),
            Text("免運費")
          ],
        ),
      ),
      Divider(),
    ],
  ),
);

} } ```

這個就是程式碼中說的選項卡,也是通過介面返回資料生成的

Simulator Screen Shot - iPhone 12 Pro - 2021-12-26 at 16.49.01.png

商品詳情頁面

```dart class ProductContentSecond extends StatefulWidget {

final List _productContentList;

const ProductContentSecond(this._productContentList, {Key? key}) : super(key: key);

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

class _ProductContentSecondState extends State {

var _flag=true;

var _id;

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

_id = widget._productContentList[0].sId;

}

@override Widget build(BuildContext context) { return Container( child: Column( children: [ _flag ? LoadingWidget() : Text(''), Expanded( child: InAppWebView( initialUrlRequest: URLRequest(url: Uri.parse("https://jdmall.itying.com/pcontent?id=${_id}")), onProgressChanged: (InAppWebViewController controller, int progress){ if (progress / 100 > 0.9999) { setState(() { this._flag = false; }); } }, ) ) ], ), ); } } ```

後面我會把整個專案的程式碼放到github.