iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 21
1
Mobile Development

Flutter---Google推出的跨平台框架,Android、iOS一起搞定系列 第 21

【Flutter基礎概念與實作】 Day21–美化首頁(1) 滑動吧!電影卡片

結束三天的Flutter測試框架介紹,回到專案本身的開發,接著來美化首頁吧。

先看今天的成品會長什麼樣子:
(gif經過壓縮後畫質會降低會有卡卡的感覺,但實際上滑動很順)


接下來就一步步來看這是如何做到的吧。

UI程式碼參考於Devefy Story App UI

下載並引入字型

Semibold
SF-Pro-Text-Bold
SF-Pro-Text-Regular

下載後放到assets資料夾,並在pubspec.yaml加上引用

HomePage

HomePage的程式碼如下,基本上和之前的架構差不多,不過為了要使用BlocBuilder所以必須從Stateless變成Stateful widget。

import 'package:flutter/material.dart';
import '../authentication_bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttube/movie/movie.dart';
import 'show_movie_widget.dart';

class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  MovieBloc _movieBloc;
  final MovieRepository _movieRepository = MovieRepository();
  int _selectIndex = 0;

  @override
  void initState() {
    _movieBloc = MovieBloc(movieRepository: _movieRepository);
    _movieBloc.dispatch(FetchTopRated(region: 'TW'));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    void _onTap(int index) {
      setState(() {
        _selectIndex = index;
      });

      switch (index) {
        case 0:
          _movieBloc.dispatch(FetchPopular(region: 'TW'));
          break;
        case 1:
          _movieBloc.dispatch(FetchNowPlaying(region: 'TW'));
          break;
        case 2:
          _movieBloc.dispatch(FetchTopRated(region: 'TW'));
          break;
      }
    }

    return Container(
      decoration: BoxDecoration(
          gradient: LinearGradient(
              colors: [
            Color(0xFF1b1e44),
            Color(0xFF2d3447),
          ],
              begin: Alignment.bottomCenter,
              end: Alignment.topCenter,
              tileMode: TileMode.clamp)),
      child: Scaffold(
        bottomNavigationBar: BottomNavigationBar(
          items: [
            BottomNavigationBarItem(
                icon: Icon(Icons.star), title: Text('Popular')),
            BottomNavigationBarItem(
                icon: Icon(Icons.play_circle_filled),
                title: Text('Now Playing')),
            BottomNavigationBarItem(
                icon: Icon(Icons.thumb_up), title: Text('Top Rated')),
          ],
          backgroundColor: Colors.amberAccent,
          onTap: _onTap,
          currentIndex: _selectIndex,
        ),
        backgroundColor: Colors.transparent,
        body: BlocBuilder(
          bloc: _movieBloc,
          builder: (context, state) {
            if (state is LoadingMovie) {
              return Center(
                child: CircularProgressIndicator(),
              );
            } else if (state is InitMovieState) {
              return Center(
                child: Text('Init Movie'),
              );
            } else if (state is PopularMovieState) {
              return ShowMovieWidget(
                movieList: state.movieList,
                category: 'Popular',
              );
            } else if (state is NowPlayingMovieState) {
              return ShowMovieWidget(
                movieList: state.movieList,
                category: 'Now Playing',
              );
            } else if (state is TopRatedMovieState) {
              return ShowMovieWidget(
                movieList: state.movieList,
                category: 'Top Rated',
              );
            }
            return Center(
              child: Text('Failed'),
            );
          },
        ),
      ),
    );
  }
}

程式碼稍微有點長,不過大多都是之前提過的東西,就從上到下挑幾個重點出來。

  1. 設定Scaffold的bottomNavigationBar用來轉換電影清單的種類(參考Day7的教學)。在_onTap根據使用者點選的button index觸發相對應的Movie Bloc Event。
  2. 用LinearGradient產生漸層的背景顏色
  3. 使用BlocBuilder監聽MovieBloc,根據不同的State轉換介面

Show Movie Widget

在home資料夾下新增「show_movie_widget.dart」(我知道名字取得很爛><)
這個widget會接收從TMDb Api傳回來的MovieList資料,以及清單種類,並用待會要新增的「card_scroll_widget」達到前面看到的滑動卡片的效果。

程式碼如下:

import '../movie/movie.dart';
import 'package:flutter/material.dart';
import 'card_scroll_widget.dart';

class ShowMovieWidget extends StatefulWidget {
  final MovieList _movieList;
  final String _category;
  ShowMovieWidget(
      {Key key, @required MovieList movieList, @override String category})
      : _movieList = movieList,
        _category = category,
        super(key: key);

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

class _ShowMovieWidgetState extends State<ShowMovieWidget> {
  var currentPage;
  MovieList get movieList => widget._movieList;
  String get category => widget._category;

  @override
  void initState() {
    currentPage = movieList.results.length - 1.0;
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    PageController controller =
    PageController(initialPage: movieList.results.length - 1);
    controller.addListener(() {
      setState(() {
        currentPage = controller.page;
      });
    });

    return Container(
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.only(
                left: 12.0, right: 12.0, top: 30.0, bottom: 8.0),
          ),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 20.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: <Widget>[
                Text(category,
                    style: TextStyle(
                      color: Colors.white,
                      fontSize: 40.0,
                      fontFamily: "Calibre-Semibold",
                      letterSpacing: 1.0,
                    )),
                Align(
                  alignment: Alignment.centerRight,
                  child: Image.asset(
                    'assets/TMDb.png',
                    height: 40,
                  ),
                )
              ],
            ),
          ),
          Stack(
            children: <Widget>[
              CardScrollWidget(
                currentPage: currentPage,
                movieList: movieList,
              ),
              Positioned.fill(
                child: PageView.builder(
                  itemCount: movieList.results.length,
                  controller: controller,
                  reverse: true,
                  itemBuilder: (context, index) {
                    return Container();
                  },
                ),
              ),
            ],
          ),
          Padding(
              padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: <Widget>[
                  FittedBox(
                    child: Text(movieList.results[currentPage.round()].title,
                        style: TextStyle(
                            color: Colors.white,
                            fontSize: 26.0,
                            fontFamily: "SF-Pro-Text-Bold")),
                  ),
                  SizedBox(
                    width: 10,
                  ),
                  Text(
                      "上映日期:${movieList.results[currentPage.round()].releaseDate}",
                      style: TextStyle(
                          color: Colors.amberAccent,
                          fontSize: 10.0,
                          fontFamily: "SF-Pro-Text-Regular"))
                ],
              )),
        ],
      ),
    );
  }
}

又是100多行的程式碼,不過別擔心,大部分的程式碼都是你看過的。

先介紹Stackwidget,一般我們使用到的widget都是獨立分開沒辦法重疊,如果想要有堆疊的效果就可以使用Stack。在Stack裡可以用Positioned設定每個widget的位置,也可以調整堆疊的效果。(了解更多細節可以參考Flutter Wiget of the Week)

為了達到滑動頁面的效果,這裡使用PageView(更多細節可以參考Flutter Wiget of the Week),它需要controller來控制目前的頁面狀況,因此可以藉由監聽controller得到當前pageView的頁數(頁數會是double值,因為有可能停留在兩頁之間),而取得的頁數可以給card_scroll_widget計算目前該顯示哪部電影的海報圖片。

所以在這裡PageView基本上是個工具人,它沒有要顯示任何東西,單純利用它能提供動態頁數的特性來達到滑動電影海報的動畫效果。

Card Scroll Widget

在home資料夾下新增「card_scroll_widget.dart」。

CardScrollWidget會需要剛剛PageView提供的頁數值以及要顯示的MovieList作為參數。

接下來會簡單說明它是如何利用計算頁數決定目前該顯示哪些電影海報而哪些該被移出畫面。

先附上程式碼:

import 'package:flutter/material.dart';
import 'dart:math';
import '../movie/movie.dart';

class CardScrollWidget extends StatelessWidget {
  final currentPage;
  final padding = 20.0;
  final verticalInset = 20.0;

  final MovieList _movieList;
  CardScrollWidget({this.currentPage, MovieList movieList})
      : _movieList = movieList;

  @override
  Widget build(BuildContext context) {
    var cardAspectRatio = 12.0 / 16.0;
    var widgetAspectRatio = cardAspectRatio * 1.2;

    return new AspectRatio(
      aspectRatio: widgetAspectRatio,
      child: LayoutBuilder(builder: (context, constraints) {
        var width = constraints.maxWidth;
        var height = constraints.maxHeight;

        var safeWidth = width - 2 * padding;
        var safeHeight = height - 2 * padding;

        var heightOfPrimaryCard = safeHeight;
        var widthOfPrimaryCard = heightOfPrimaryCard * cardAspectRatio;

        var primaryCardLeft = safeWidth - widthOfPrimaryCard;
        var horizontalInset = primaryCardLeft / 2;

        List<Widget> cardList = new List();

        for (var i = 0; i < _movieList.results.length; i++) {
          var delta = i - currentPage;
          bool isOnLeft = delta > 0;
          var start = padding +
              max(
                  primaryCardLeft - horizontalInset * -delta * (isOnLeft ? 15 : 1),
                  0.0);
          var cardItem = Positioned.directional(
            top: padding + verticalInset * max(-delta, 0.0),
            bottom: padding + verticalInset * max(-delta, 0.0),
            start: start,
            textDirection: TextDirection.rtl,
            child: ClipRRect(
              borderRadius: BorderRadius.circular(16.0),
              child: Container(
                decoration: BoxDecoration(color: Colors.white, boxShadow: [
                  BoxShadow(
                      color: Colors.black12,
                      offset: Offset(3.0, 6.0),
                      blurRadius: 10.0)
                ]),
                child: AspectRatio(
                  aspectRatio: cardAspectRatio,
                  child: FadeInImage.assetNetwork(
                    image:
                    'https://image.tmdb.org/t/p/w185${_movieList.results[i].posterPath}',
                    placeholder: 'assets/no.jpg',
                    fit: BoxFit.fill,),
                ),
              ),
            ),
          );
          cardList.add(cardItem);
        }
        return Stack(
          children: cardList,
        );
      }),
    );
  }
}

滑動卡片的秘密是由簡單的數學計算組成的,對於看到數學就不舒服的人可以跳過這段,因為我解釋得滿爛的XD

判斷電影海報該顯示和消失的關鍵值是var delta = i - currentPage;是電影海報在MovieList中的index,currentPage是目前的頁數(注意越左邊的頁數值越大,最右邊的值為0,和一般的直覺相反)。
舉個例子:

  1. 若currentPage是0(目前顯示最右邊的頁面),這樣每個電影海報的delta值都會是>=0,越往左delta越大。
  2. 若currentPage是最大值(目前顯示最左邊的頁面),這樣每個電影海報的delta值都會是<=0,越往右delta越小。
    統整起來可以得到結論,只要delta>0,電影海報就在當前頁面的左邊=消失;反過來說delta<0,電影海報就在當前頁面的右手邊。

接下來是startstart的值表示widget和起始點距離有多遠(起始點通常是從左到右ltr,不過這裡是從右到左rtl)。
所以start=100相當於和右邊邊界相差100單位距離,如果單位距離超出螢幕寬度,它就會消失不見。

有了這個觀念後來看start是如何使用delta計算的。

var start = padding +
    max(primaryCardLeft - horizontalInset * -delta * (isOnLeft ? 15 : 1), 0.0);

並不用知道primaryCardLefthorizontalInset是什麼,只要知道這個公式中的delta越大,算出來的start也會越大,也就是電影海報會在越左邊的地方,然後隨著delta變小又會回到畫面上。

所以實際上我們看到的其實是delta隨著pageView頁數變化不停地更新介面所形成的動畫。

把start值print出來就能很明顯地看到,往右滑越左邊的start值就會越大。

今日總結

今天使用bottomNavigationBar結合BlocBuilder讓我們可以在首頁切換不同的電影清單類型,並且改造了原本單調的gridView,改用更加動態的pageView方式呈現。

藉由Devefy的教學程式碼學會如何做出滑動卡片的效果,如果你有興趣甚至可以結合之前提過的transform做出更酷的轉場效果(可以參考這邊)。

明天會繼續改造首頁,但不會像今天有這麼多程式碼,所以就明天見吧。

完整程式碼在這裡-> FlutTube Github


上一篇
【Flutter基礎概念與實作】 Day20–測試Movie API和Movie BLoC
下一篇
【Flutter基礎概念與實作】 Day22–美化首頁(2) 增加Drawer和標題文字動畫
系列文
Flutter---Google推出的跨平台框架,Android、iOS一起搞定30

尚未有邦友留言

立即登入留言