Flutter開發實戰分析-pesto_demo解析

deep_sadness發表於2018-08-13

入門介紹完,今天我們,先來分析幾個官方提供的示例。

以下程式碼來源於 flutter_gallery中的pesto_demo示例

1. PESTO菜譜

pesto.gif

0.需求分析

分析layout

  • 有頂部的appBarfloatingActionButton
  • 下面的列表是由CardView組成的listView

分析動畫

  • 頭部的Toolbar是可以伸縮的頭部,並且帶有動畫(重點和難點)
  • 轉場動畫

分析事件

  • 點選搜尋和floatingActionButton彈出SnackBar
  • 儲存選單的喜歡的狀態

1.動手

初始化

  • 資料結構和假資料
//0.定義好資料結構
//從圖中可以看到,列表頁得需要得是下面幾個欄位
class Recipe {
  const Recipe(
      {this.name,
      this.author,
      this.ingredientsImagePath,
      this.description,
      this.imagePath,
      this.ingredients,
      this.steps});

  final String name;
  final String author;
  final String description;
  final String ingredientsImagePath;
  final String imagePath;

  //這兩個欄位是詳情頁需要得
  final List<RecipeIngredient> ingredients;
  final List<RecipeStep> steps;
}

//詳情頁需要得
class RecipeIngredient {
  const RecipeIngredient({this.amount, this.description});

  final String amount;
  final String description;
}

class RecipeStep {
  const RecipeStep({this.duration, this.description});

  final String duration;
  final String description;
}
複製程式碼
  • Theme 整體的主題風格是亮色系,顏色是綠色,accentColor是紅色
//0.寫好主題
final PestoHomeThemeData = ThemeData(
    brightness: Brightness.light,
    primaryColor: Colors.teal,
    accentColor: Colors.redAccent);
複製程式碼
  • 快取喜歡的結果
//還需要一個儲存是否喜歡得欄位
final Set<Recipe> _favoriteRecipes = new Set<Recipe>();
複製程式碼
  • Scaffold
  • 因為頂部的appBarfloatingActionButton,所以要最完成需要使用Scaffold。而且 而它的bodyappBar,一個是需要填充資料的列表,一個是需要動畫的appBar。所以是一個StatefulWidget。 而整體是一個頁面,因為要不斷傳遞我們的_favoriteRecipes,所以又封裝了一層StatelessWidget.

靜態部分

我們暫時先不管滾動的部分。

  • 將原來的Theme的platform保留
class PestoHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) =>
      //傳遞_favoriteRecipes給它
      RecipeGridPage(recipes: _favoriteRecipes.toList());
}

class RecipeGridPage extends StatefulWidget {
  final List<Recipe> recipes;

  RecipeGridPage({Key key, @required this.recipes}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _RecipeGridPageState();
}

class _RecipeGridPageState extends State<RecipeGridPage> {
  final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();

  @override
  Widget build(BuildContext context) {
    //因為需要floatingActionButton,所以需要Scaffold
    return Theme(
         //將context中的platform資訊保留
        data: _pTheme.copyWith(platform: Theme.of(context).platform),
        child: Scaffold(
          key: scaffoldKey,
          floatingActionButton: FloatingActionButton(
              onPressed: null),
          body: null,
        ));
  }
}
複製程式碼

FloatingActionButton

先把FloatingActionButton 完成。就是簡單的彈出SnackBar的功能。 彈出SnackBar,需要Scaffold的BuildContext。通過之前的學習,我們知道有3個可以得到的方式(ScaffoldGlobalKeybuilder方法得到正確的BuildContext,或者直接寫成子元件)。

  • 這裡採用的是GlobalKey的方式 這種方式最簡單了。使用GlobalKey的方式,其他要彈的,都可以快速拿到state
 floatingActionButton: FloatingActionButton(
              child: Icon(Icons.edit),
              onPressed: () {
                //直接使用scaffoldKey.currentState彈出
                scaffoldKey.currentState
                    .showSnackBar(SnackBar(content: Text('Not supported.')));
              }),
複製程式碼

Appbar(暫時)

  • 程式碼 先新增一個暫時的AppBar,滑動動畫的部分,我們會後面處理
class _RecipeGridPageState extends State<RecipeGridPage> {
  final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
  List<Recipe> items = kPestoRecipes;

  @override
  Widget build(BuildContext context) {
    print('items.length=${items.length}');
    //因為需要floatingActionButton,所以需要Scaffold
    return Theme(
        //將context中的platform資訊保留
        data: _pTheme.copyWith(platform: Theme.of(context).platform),
        child: Scaffold(
            key: scaffoldKey,
            appBar: AppBar(
              title: Text('靜態頁面'),
              actions: <Widget>[
                GestureDetector(
                  onTap: () {
                    scaffoldKey.currentState.showSnackBar(
                        SnackBar(content: Text('Not supported.')));
                  },
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Icon(Icons.search),
                  ),
                )
              ],
            ),
            floatingActionButton: FloatingActionButton(
                child: Icon(Icons.edit),
                onPressed: () {
                  //直接使用scaffoldKey.currentState彈出
                  scaffoldKey.currentState
                      .showSnackBar(SnackBar(content: Text('Not supported.')));
                }),
            body: null)
);
  }
複製程式碼
  • 結果
    pesto.gif
    圖中分別的操作是,第一次點選右上角。第二次點選右下角按鈕。都彈出了SnackBar。和預期一樣。

body部分

  • recipe card
    image.png
寫好字型的樣式
class PestoStyle extends TextStyle {
  const PestoStyle({
    double fontSize: 12.0,
    FontWeight fontWeight,
    Color color: Colors.black87,
    double letterSpacing,
    double height,
  }) : super(
    inherit: false,
    color: color,
    fontFamily: 'Raleway',
    fontSize: fontSize,
    fontWeight: fontWeight,
    textBaseline: TextBaseline.alphabetic,
    letterSpacing: letterSpacing,
    height: height,
  );
}

 final TextStyle titleStyle = const PestoStyle(fontSize: 24.0, fontWeight: FontWeight.w600);
 final TextStyle authorStyle = const PestoStyle(fontWeight: FontWeight.w500, color: Colors.black54);

複製程式碼
確定好整體的佈局
  • 如上圖分析,大體的佈局就是這樣。
  • 因為是MD中Card的樣式,所以需要在最外層包裹一層Card
  • 同時,圖中未標註的是,padding的部分。在Flutter中,要實現padding,只要在它包裹在外面一層佈局下就可以了。
封裝成Card元件
  • 封裝元件
class RecipeCard extends StatelessWidget {
  final TextStyle titleStyle =
      const PestoStyle(fontSize: 24.0, fontWeight: FontWeight.w600);
  final TextStyle authorStyle =
      const PestoStyle(fontWeight: FontWeight.w500, color: Colors.black54);

  RecipeCard({Key key, @required this.recipe}) : super(key: key);

  final Recipe recipe;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Image.asset(
            recipe.imagePath,
            fit: BoxFit.contain,
          ),
          Row(
            children: <Widget>[
              new Padding(
                padding: const EdgeInsets.all(16.0),
                child: new Image.asset(
                  recipe.ingredientsImagePath,
                  width: 48.0,
                  height: 48.0,
                ),
              ),
              new Expanded(
                child: new Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    new Text(recipe.name,
                        style: titleStyle,
                        softWrap: false,
                        overflow: TextOverflow.ellipsis),
                    new Text(recipe.author, style: authorStyle),
                  ],
                ),
              ),
            ],
          )
        ],
      ),
    );
  }
}
複製程式碼

然後我們先修改程式碼,先預覽一下這個RecipeCard是否滿足我們的需求。

//修改_RecipeGridPageState build方法
class _RecipeGridPageState extends State<RecipeGridPage> {
  final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
  List<Recipe> items = kPestoRecipes;

  @override
  Widget build(BuildContext context) {
    print('items.length=${items.length}');
    //因為需要floatingActionButton,所以需要Scaffold
    return Theme(
           //省去不需要修改的部分...
            //ListView相當於Android中的RecycleView
            body: ListView.builder(
                //顯示的數量,就是item的數量
                itemCount: items.length,
                itemBuilder: (context, index) {
                  //將我們封裝好的提供出去
                  return RecipeCard(recipe: items[index]);
                })));
  }
}
複製程式碼
  • 效果預覽

    image.png

  • 新增onTap事件監聽 確實達到了我們的效果。 我們還預期點選item,跳轉到詳情頁。那我們給RecipeCard新增手勢,並將點選事件傳入。

//省略不修改的程式碼
class RecipeCard extends StatelessWidget {
  //新增點選事件的回撥
  RecipeCard({Key key, @required this.recipe,this.onTap}) : super(key: key);
  final VoidCallback onTap;
  @override
  Widget build(BuildContext context) {
    //使用GestureDetector來包裹,獲取事件
    return GestureDetector(
      onTap: onTap,
      child: Card(//省略重複程式碼
       ),
    );
  }
}

class _RecipeGridPageState extends State<RecipeGridPage> {
  @override
  Widget build(BuildContext context) {
    return Theme(
        //將context中的platform資訊保留
        data: _pTheme.copyWith(platform: Theme.of(context).platform),
        child: Scaffold(
            key: scaffoldKey,
            floatingActionButton: FloatingActionButton(
            //省略...            
            ),
            body: ListView.builder(
                itemCount: items.length,
                itemBuilder: (context, index) {
                  return RecipeCard(
                    recipe: items[index],
                    //傳入點選事件
                    onTap: () {
                      showRecipePage(context, items[index]);
                    },
                  );
                })));
  }
  //需要顯示我們的商品詳情頁
  void showRecipePage(BuildContext context, Recipe item) {
  }
}
複製程式碼
商品詳情頁

pesto.gif
同樣,我們也只先實現下面的部分。

  • 分析
  1. 我們發現,介面是由兩個重疊的元素形成的。 一個是下面的列表,另一個是疊在上面的floattingButton
  2. RecipeSheet離頂部有一般floattingButton高度的距離。
  3. floattingButton距離右邊有一定距離。

這樣我們就使用StackPosition來完成我們的定位。

  • 程式碼
class _RecipePageState extends State<RecipePage> {
  final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();

  @override
  Widget build(BuildContext context) {
    final bool isFavorite = _favoriteRecipes.contains(widget.recipe);
    return new Scaffold(
      key: _scaffoldKey,
      body: new Stack(
        children: <Widget>[
          new ListView(
            children: <Widget>[
              new Stack(
                children: <Widget>[
                  new Container(
                    padding: const EdgeInsets.only(top: _kFabHalfSize),
                    child: new RecipeSheet(recipe: widget.recipe),
                  ),
                  new Positioned(
                    right: 16.0,
                    child: new FloatingActionButton(
                      child: new Icon(
                          isFavorite ? Icons.favorite : Icons.favorite_border),
                      onPressed: _toggleFavorite,
                    ),
                  ),
                ],
              )
            ],
          )
        ],
      ),
    );
  }
  void _toggleFavorite() {
    setState(() {
      if (_favoriteRecipes.contains(widget.recipe))
        _favoriteRecipes.remove(widget.recipe);
      else
        _favoriteRecipes.add(widget.recipe);
    });
  }
}

class RecipeSheet extends StatelessWidget {
  final TextStyle titleStyle = const PestoStyle(fontSize: 34.0);
  final TextStyle descriptionStyle = const PestoStyle(
      fontSize: 15.0, color: Colors.black54, height: 24.0 / 15.0);
  final TextStyle itemStyle =
      const PestoStyle(fontSize: 15.0, height: 24.0 / 15.0);
  final TextStyle itemAmountStyle = new PestoStyle(
      fontSize: 15.0, color: _pTheme.primaryColor, height: 24.0 / 15.0);
  final TextStyle headingStyle = const PestoStyle(
      fontSize: 16.0, fontWeight: FontWeight.bold, height: 24.0 / 15.0);

  RecipeSheet({Key key, this.recipe}) : super(key: key);

  final Recipe recipe;

  @override
  Widget build(BuildContext context) {
    return new Material(
      child: new Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 40.0),
        child: new Table(
          columnWidths: const <int, TableColumnWidth>{
            0: const FixedColumnWidth(64.0)
          },
          children: <TableRow>[
            new TableRow(children: <Widget>[
              new TableCell(
                  verticalAlignment: TableCellVerticalAlignment.middle,
                  child: new Image.asset(recipe.ingredientsImagePath,
                      width: 32.0,
                      height: 32.0,
                      alignment: Alignment.centerLeft,
                      fit: BoxFit.scaleDown)),
              new TableCell(
                  verticalAlignment: TableCellVerticalAlignment.middle,
                  child: new Text(recipe.name, style: titleStyle)),
            ]),
            new TableRow(children: <Widget>[
              const SizedBox(),
              new Padding(
                  padding: const EdgeInsets.only(top: 8.0, bottom: 4.0),
                  child: new Text(recipe.description, style: descriptionStyle)),
            ]),
            new TableRow(children: <Widget>[
              const SizedBox(),
              new Padding(
                  padding: const EdgeInsets.only(top: 24.0, bottom: 4.0),
                  child: new Text('Ingredients', style: headingStyle)),
            ]),
          ]
            ..addAll(recipe.ingredients.map((RecipeIngredient ingredient) {
              return _buildItemRow(ingredient.amount, ingredient.description);
            }))
            ..add(new TableRow(children: <Widget>[
              const SizedBox(),
              new Padding(
                  padding: const EdgeInsets.only(top: 24.0, bottom: 4.0),
                  child: new Text('Steps', style: headingStyle)),
            ]))
            ..addAll(recipe.steps.map((RecipeStep step) {
              return _buildItemRow(step.duration ?? '', step.description);
            })),
        ),
      ),
    );
  }

  TableRow _buildItemRow(String left, String right) {
    return new TableRow(
      children: <Widget>[
        new Padding(
          padding: const EdgeInsets.symmetric(vertical: 4.0),
          child: new Text(left, style: itemAmountStyle),
        ),
        new Padding(
          padding: const EdgeInsets.symmetric(vertical: 4.0),
          child: new Text(right, style: itemStyle),
        ),
      ],
    );
  }
}
複製程式碼

注意:

  1. 這裡需要注意的是..這種語法。這是dart的語法。相當於呼叫後面的方法,然後返回本身這樣的操作。
  2. Table TableRowTableCell都是Flutter中提供的表格控制元件。
  • 效果圖
    pesto.gif

然後修改跳轉的程式碼

void showRecipePage(BuildContext context, Recipe item) {
    Navigator.push(context, new MaterialPageRoute<void>(
      settings: const RouteSettings(name: '/pesto/recipe'),
      builder: (BuildContext context) {
        return new Theme(
          data: _pTheme.copyWith(platform: Theme.of(context).platform),
          child: new RecipePage(recipe: item),
        );
      },
    ));
  }
複製程式碼

動態部分

理論認識

因為我們需要appBar進行滑動。所以需要使用CustomScrollView。結合SliverAppBarSliverGrid來進行整體的繪製。

CustomScrollView
  • 使用它,可以結合Sliver來創造自定義的滾動效果。 比如說 做一個MD中常用的app bar 擴充套件的效果,就可以使用SliverAppBarSliverListSliverGrid來完成。
  • 會創造RenderSliver物件。
  • 還可以通過NotificationListener來監聽滾動事件,或者通過ScrollController來監聽和控制滾動事件。

很多經典的MD的appBar部分動畫,都可以得到相應的實現。

觀察動畫

商品詳情頁

我們發現,商品詳情頁的動畫效果,有點像是MD內,appbar放一張圖片,然後完全滾動遮蓋的效果。 所以,我們先用這個效果來嘗試以下,實現效果

  • 程式碼
class _RecipePageState extends State<RecipePage> {
  final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();

  double _getAppBarHeight(BuildContext context) =>
      MediaQuery.of(context).size.height * 0.3;

  @override
  Widget build(BuildContext context) {
    final bool isFavorite = _favoriteRecipes.contains(widget.recipe);
    final double appBarHeight = _getAppBarHeight(context);

    return new Scaffold(
        key: _scaffoldKey,
        //將body直接改為CustomScrollView
        body: CustomScrollView(
          slivers: <Widget>[
            //分別返回`SliverAppBar`和`SliverToBoxAdapter`
            SliverAppBar(
              expandedHeight: appBarHeight - _kFabHalfSize,
              backgroundColor: Colors.transparent,
              //這個是決定appBar有多大的和裡面放東西的控制元件
              flexibleSpace: FlexibleSpaceBar(
                  //建立一個stack
                  background: Stack(
                    fit: StackFit.expand,
                    children: <Widget>[
                      //先放一層圖片在下面
                      Image.asset(
                        widget.recipe.imagePath,
                        fit: BoxFit.cover,
                        height: appBarHeight - _kFabHalfSize,
                      ),
                      //再蓋一層漸變色
                      DecoratedBox(
                          decoration: BoxDecoration(
                            gradient: LinearGradient(
                                colors: <Color>[Color(0x60000000), Color(0x00000000)],
                                begin: Alignment(0.0, -1.0),
                                end: Alignment(0.0, -0.2)),
                          ))
                    ],
                  )),
            ),
            //因為child接受的是Sliver,我們可以將Box的控制元件,使用SliverToBoxAdapter來包括,簡單的就可以顯示了
            SliverToBoxAdapter(
              child: new Stack(
                children: <Widget>[
                  //這裡和原來一樣。同樣是要疊兩層。因為floattingActionBar是突出半個
                  Container(
                    width: _kRecipePageMaxWidth,
                    padding: const EdgeInsets.only(top: _kFabHalfSize),
                    child: new RecipeSheet(recipe: widget.recipe),
                  ),
                  Positioned(
                    right: 16.0,
                    child: new FloatingActionButton(
                      child: new Icon(isFavorite
                          ? Icons.favorite
                          : Icons.favorite_border),
                      onPressed: _toggleFavorite,
                    ),
                  ),
                ],
              ),
            )
          ],
        ));
  }
  //省略相同部分...
}
複製程式碼
  1. Scaffoldbody下直接使用CustomScrollView
  2. SliverAppBar中的flexibleSpace來存放appBar內顯示的其他控制元件
  3. 預設的 SliverAppBarpinedfalse,故他會跟著滾上去。
  4. 因為CustomScrollViewslivers接受的是sliver,我們可以將Box的控制元件,使用SliverToBoxAdapter來包括,簡單的就可以顯示了
  • 執行效果
    22.gif

仔細看,有兩點效果還是不滿足我們預期的效果。

  • FloatingActionButton,需要壓住一點上面的圖片。
  • 滾動時,我們不需要圖片進行透明度的漸變。
再次修改

既然這樣,我們就不能用自帶的來完成效果了。再次觀察預期的效果,發現,關鍵點:背後的圖片是不動的。 所以我們想,讓圖片整個放在背後,appBar只是一個透明的遮罩!

  • 程式碼
class _RecipePageState extends State<RecipePage> {
  //省略重複部分....
  @override
  Widget build(BuildContext context) {
   //省略重複部分....
    return new Scaffold(
        key: _scaffoldKey,
        //0.將body替換成一個Stack
        body: Stack(
          children: <Widget>[
            //將圖片跌在最下一層。並放在頂部
             Positioned(
              child: Image.asset(
                widget.recipe.imagePath,
                fit: BoxFit.cover,
                height: appBarHeight+ _kFabHalfSize,
              ),
              top: 0.0,
              left: 0.0,
              right: 0.0,
            ),![33.gif](https://upload-images.jianshu.io/upload_images/1877190-6544b5dccd3bd690.gif?imageMogr2/auto-orient/strip)

            //然後再疊放我們的ScrollView
            CustomScrollView(
              slivers: <Widget>[
                SliverAppBar(
                  expandedHeight: appBarHeight - _kFabHalfSize,
                  backgroundColor: Colors.transparent,
                  pinned: false,
                  //flexibleSpace 的background只是一個遮罩
                  flexibleSpace: FlexibleSpaceBar(
                      background: DecoratedBox(
                          decoration: BoxDecoration(
                    gradient: LinearGradient(
                        colors: <Color>[Color(0x60000000), Color(0x00000000)],
                        begin: Alignment(0.0, -1.0),
                        end: Alignment(0.0, -0.2)),
                  ))),//Stack,FlexibleSpaceBar
                ),  //SliverAppBar
                SliverToBoxAdapter(//... )  //SliverToBoxAdatper
              ],//<Widget>[]
            ),//CustomScrollView
          ],  //<WIdget>[]
        ));  //stack,Scaffold
  }

}
複製程式碼
  • 執行效果
    33.gif

確認過眼神,就是我們要的效果。商品詳情頁的動畫完成~

首頁

同樣的,我們發現預設的效果並不滿足我們。我們這裡需要根據滾動的量去改變FlexibleSpaceBar內我們建立的logo和圖示的大小。

  • 第一步,先改成經典的MD樣式
class _RecipeGridPageState extends State<RecipeGridPage> {

  //省略程式碼...

  @override
  Widget build(BuildContext context) {
//得到狀態列高度
    final double statusBarHeight = MediaQuery.of(context).padding.top;

    return Theme(
        data: _pTheme.copyWith(platform: Theme.of(context).platform),
        child: Scaffold(
            key: scaffoldKey,
            floatingActionButton: FloatingActionButton(
                child: Icon(Icons.edit),
                onPressed: () {
                  //直接使用scaffoldKey.currentState彈出
                  scaffoldKey.currentState
                      .showSnackBar(SnackBar(content: Text('Not supported.')));
                }),
            //將body改為CustomScrollView
            body: CustomScrollView(
              slivers: <Widget>[
                SliverAppBar(
                  //pinned為true ,這樣就不會隨著繼續往上滑動
                  pinned: true,
                  expandedHeight: _kAppBarHeight,
                  backgroundColor: Colors.teal,
                  //將原來放在appbar的action放在這裡
                  actions: <Widget>[
                    GestureDetector(
                      onTap: () {
                        scaffoldKey.currentState.showSnackBar(
                            SnackBar(content: Text('Not supported.')));
                      },
                      child: Padding(
                        padding: const EdgeInsets.all(8.0),
                        child: Icon(Icons.search),
                      ),
                    )
                  ],
                  //這裡新增。繪製出我們的圖示
                  flexibleSpace: FlexibleSpaceBar(
                    background: Padding(
                      padding: new EdgeInsets.only(
                        top: statusBarHeight + 0.5 * 10.0,
                        bottom: 10.0,
                      ),
                      child: Center(
                       //固定寬度的居中處理
                        child: SizedBox(
                          width: kLogoWidth,
                          //使用stack展示上下佈局。為什麼不用column?
                          child: Stack(
                            overflow: Overflow.visible,
                            children: <Widget>[
                              Positioned.fromRect(
                                rect: Rect.fromLTWH(
                                    0.0, 0.0, kLogoWidth, kLogoHeight/5*2),
                                child: new Image.asset(
                                  'flutter_gallery_assets/pesto/logo_small.png',
                                  fit: BoxFit.contain,
                                ),
                              ),
                              Positioned.fromRect(
                                rect: Rect.fromLTWH(
                                    0.0, kLogoHeight/5*2, kLogoWidth, kTextHeight),
                                child: Center(
                                  child: new Text('PESTO',
                                      style: logoTitleStyle,
                                      textAlign: TextAlign.center),
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              //下面是一個list.把原來的listView.builder改成這樣就可以了
                SliverList(
                    delegate: SliverChildBuilderDelegate(
                  (context, index) {
                    return RecipeCard(
                      recipe: items[index],
                      onTap: () {
                        showRecipePage(context, items[index]);
                      },
                    );
                  },
                  childCount: items.length,
                )),
              ],
            )));
  }
  
//省略程式碼...
}
複製程式碼
  • 效果1
    33.gif

確實不符合我們的效果,接下來需要動畫控制整個效果。

  • 正在的技術 我們這裡的效果是根據appBar的大小,進行圖示的縮放,最後保留圖示,停留在那。

我們可以使用LayoutBuilder這個類,來傳遞變化的父元件的約束。

LayoutBuilder

還記得我們入門的第二遍文章介紹過的Builder嗎(可以正確傳入當前子控制元件的父元件的BuildContext)?與其類似的,還存在 LayoutBuilder。它可以傳入父元件的大小,讓我們的自元件跟著他進行變化。

FlexibleSpaceBar修改成LayoutBuilder就可以得到變化的Contraints了。 題外話:FlexibleSpaceBar的實現方式和這種方式不同。這個我們後面再研究

  • 程式碼
 flexibleSpace: LayoutBuilder(builder:
                        (BuildContext context, BoxConstraints constraints) {
                      print('constraints=' + constraints.toString());
                    return Padding(//...與原來相同的程式碼
                              );
}
複製程式碼
  • 效果2
    33.gif
    看到這時候,之前FlexibleSpaceBar自帶的漸變效果就消失了, 還可以可以看到這個constraints的高度在變化

image.png

這樣,我們就根據這樣的數值,來完成我們的動畫效果

  • 程式碼
flexibleSpace: LayoutBuilder(builder:
                        (BuildContext context, BoxConstraints constraints) {
                      //這是AppBar的總高度
                      double biggestHeight = constraints.biggest.height;
                      //當前的AppBar的真實高度,去掉了狀態列
                      final double appBarHeight = biggestHeight-statusBarHeight;
                      //appBarHeight - kToolbarHeight 代表的是當前的擴充套件量,_kAppBarHeight - kToolbarHeight表示最大的擴充套件量

                      //t就是,變化的Scale
                      final double t = (appBarHeight - kToolbarHeight) / (_kAppBarHeight - kToolbarHeight);
                      // begin + (end - begin) * t; lerp函式可以快速取到根據當前的比例中間值
                      final double extraPadding = new Tween<double>(begin: 10.0, end: 24.0).lerp(t);
                      final double logoHeight = appBarHeight - 1.5 * extraPadding;

                      //字型的樣式沒有發生變化。
                      final TextStyle titleStyle = const PestoStyle(fontSize: kTextHeight, fontWeight: FontWeight.w900, color: Colors.white, letterSpacing: 3.0);

                      //字型所佔用的rect空間
                      final RectTween _textRectTween = new RectTween(
                          begin: new Rect.fromLTWH(0.0, kLogoHeight, kLogoWidth, kTextHeight),
                          end: new Rect.fromLTWH(0.0, kImageHeight, kLogoWidth, kTextHeight)
                      );
                      //透明度變化的曲線。這裡是easeInOut
                      final Curve _textOpacity = const Interval(0.4, 1.0, curve: Curves.easeInOut);

                      //圖片所佔用的rect空間
                      final RectTween _imageRectTween = new RectTween(
                        begin: new Rect.fromLTWH(0.0, 0.0, kLogoWidth, kLogoHeight),
                        end: new Rect.fromLTWH(0.0, 0.0, kLogoWidth, kImageHeight),
                      );

                      return Padding(
                        padding: new EdgeInsets.only(
                          //這個padding就直接設定變化
                          top: statusBarHeight + 0.5 * extraPadding,
                          bottom:extraPadding,
                        ),
                        child: Center(
                          child: Transform(
                            //因為整體需要一個Scale的變化,所以就用transform.可以理解成css一樣的transfrom動畫。
                            //這裡是使用單位矩陣*scale來計算.scale等於當前logo的高度佔總共的高度
                            transform: new Matrix4.identity()..scale(logoHeight / kLogoHeight),
                            //佈置在上中
                            alignment: Alignment.topCenter,
                            child: SizedBox(
                              width: kLogoWidth,
                              child: Stack(
                                overflow: Overflow.visible,
                                children: <Widget>[
                                  Positioned.fromRect(
                                    //這裡傳遞的佔用位置也是不斷變化的,這裡說明其實我們外層其實也可以用SizedBox來實現?
                                    rect: _imageRectTween.lerp(t),
                                    child: new Image.asset(
                                      'flutter_gallery_assets/pesto/logo_small.png',
                                      fit: BoxFit.contain,
                                    ),
                                  ),
                                  Positioned.fromRect(
                                    rect: _textRectTween.lerp(t),
                                    child: Center(
                                      //建立一個透明度來包裹
                                      child: Opacity(
                                        //找到這個曲線上t百分比佔的位置
                                        opacity: _textOpacity.transform(t),
                                        child: new Text('PESTO',
                                            style: titleStyle,
                                            textAlign: TextAlign.center),
                                      ),
                                    ),
                                  ),
                                ],
                              ),
                            ),
                          ),
                        ),
                      );
                    })
複製程式碼
  • 最終效果
    cc.gif
    可以觀察到,需要的實現效果有三個
  1. 上下的padding發生改變 通過直接改變包裹的padding值來改變。
 new EdgeInsets.only(
                          //這個padding就直接設定變化
                          top: statusBarHeight + 0.5 * extraPadding,
                          bottom:extraPadding,
                        ),
複製程式碼
  1. 整體變小 通過在包裹一層,Transfrom元件,改變其中的矩陣來完成。 還有一個就是SizedBox中定義的Rect來控制佔用的控制元件。並不會Scale控制元件
child: Transform(
                            //因為整體需要一個Scale的變化,所以就用transform.可以理解成css一樣的transfrom動畫。
                            //這裡是使用單位矩陣*scale來計算.scale等於當前logo的高度佔總共的高度
                            transform: new Matrix4.identity()
                              ..scale(logoHeight / kLogoHeight),
                            //佈置在上中
                            alignment: Alignment.topCenter,
                            child: SizedBox(
                              width: kLogoWidth,
                              child: Stack(
                                overflow: Overflow.visible,
                                children: <Widget>[
                                  Positioned.fromRect(
                                    //這裡傳遞的佔用位置也是不斷變化的,這裡說明其實我們外層其實也可以用SizedBox來實現?
                                    rect: _imageRectTween.lerp(t),
                                    child: new Image.asset(
                                      'flutter_gallery_assets/pesto/logo_small.png',
                                      fit: BoxFit.contain,
                                    ),
                                  ),
                                  Positioned.fromRect(
                                    rect: _textRectTween.lerp(t),
                                    child: Center(
                                      //建立一個透明度來包裹
                                      child: Opacity(
                                        //找到這個曲線上t百分比佔的位置
                                        opacity: _textOpacity.transform(t),
                                        child: new Text('PESTO',
                                            style: titleStyle,
                                            textAlign: TextAlign.center),
                                      ),
                                    ),
                                  ),
                                ],
                              ),
                            ),
                          ),
複製程式碼
  1. 下面的文字有一個透明度的改變 通過包裹一層透明度元件(Opacity),修改opacity的值,來完成。
Opacity(
                                        //找到這個曲線上t百分比佔的位置
                                        opacity: _textOpacity.transform(t),
                                        child: new Text('PESTO',
                                            style: titleStyle,
                                            textAlign: TextAlign.center),
                                      ),
複製程式碼

#####新增轉場動畫效果 使用Hero元件包裹Image,並且同時帶有相同的tag

  • RecipeCard
class RecipeCard extends StatelessWidget {
  //省略無用程式碼
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Card(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
          //這裡進行包裹
            Hero(
              tag: "${recipe.imagePath}",
              child: Image.asset(
                recipe.imagePath,
                fit: BoxFit.contain,
              ),
            ),
            Row(
            //省略重複程式碼
           )
          ],
        ),
      ),
    );
  }
}
複製程式碼
  • _RecipePageState
//...省略
@override
  Widget build(BuildContext context) {
    return new Scaffold(
        key: _scaffoldKey,
        body: Stack(
          children: <Widget>[
            Positioned(
             //同樣在這裡包裹住圖片
              child: Hero(
                tag: "${widget.recipe.imagePath}",
                child: Image.asset(
                  widget.recipe.imagePath,
                  fit: BoxFit.cover,
                  height: appBarHeight + _kFabHalfSize,
                ),
              ),
              top: 0.0,
              left: 0.0,
              right: 0.0,
            ),
            CustomScrollView(//...省略
            ),
          ],
        ));
  }
複製程式碼

總結

最後總結一下。 看到這樣一個,不屬於自帶效果的動畫,我們剛剛開始確實無法入手。 遇到這樣的方法,最簡單的也是最耗時方式就是降維。就像本編文章一樣,花了大量的事件,先完成靜態簡單的熟悉的頁面。再完成動態的效果。

這邊文章我們熟悉的知識點,可以簡單做一下回顧

  1. 封裝一個簡單的Card元件
  2. 使用ListView.Builder來顯示一個列表
  3. 使用GestureDetector來監聽手勢事件
  4. Stack佈局的使用。(可以理解成FrameLayout)
  5. dart的..的級聯用法。(這個用法超級常見和方便)
  6. Table TableRow 和TableCell元件來顯示簡單的表單功能
  7. 使用CustomScrollView結合SliverAppBar和SliverList來實現經典的MD動畫效果
  8. 使用CustomScrollView等元件,結合LayoutBuilder來實現自定義的動畫效果。 LayoutBuilder會傳入父元件的約束。我們就可以通過傳入的約束,計算變化量。並利用一系列內建的動畫元件Transfrom Opacity等,來進行變化。

相關文章