[Flutter] 學習做 Page 之間滑動時淡入淡出圖片

學習利用 PageView 搭配 PageController 做出滑動式前後 Page 淡入淡出的效果。

有些 App 在第一次啓動時顯示一系列的畫面,提供教學或説明該 App 有什麽功能,例如: 剛好最近也有做到類似的需求,因此,我藉由找到幾篇教學來説明如何做到。 要做到多個 Page 之間活動切換,可以使用 PageView 來做到。 説明幾個重要的地方:

  • PageView
    提供整頁畫面可滑動切換的基本元件,每一頁都是相同大小。 適合一些結構比較簡單的情況使用,例如:多張照片滑動瀏覽,或是廣告版位的自動滑動等。 可搭配 PageController 控制在滑動時的移動效果(offset)或是調整顯示的大小或坐標等。 例如下面簡單的例子:
    PageView(
      children: [
        Container(
          color: Colors.yellow,
        ),
        Container(
          color: Colors.blue,
        ),
        Container(
          color: Colors.deepPurple,
        ),
      ],
    )
    幾個屬性可以設定來做一些調整:
    • reverse:設定是否要 Scroll 的方向是否反過來;
    • scrollDirection:設定 Scroll 是要左右滑動或是上下滑動;
    • pageSnapping:設定滑動時是否自動下一頁,如果設定 false,在滑動時會卡住前後一頁;
    在 PageView 在顯示時只會顯示目前要顯示的 Page,當滑動時才會根據滑動的 offset 決定要生成前面或後面的 Page。
    例如:假設 PageView 加入了 A,B,C,D 四個 Page,首先會顯示 A,如果往左邊滑動時,PageView 會生成:A -> half of B & half of A -> B; 假設在 B 往右邊滑動時,PageView 會生成: B-> half of A & half of B -> A;
    而這樣的邏輯剛好可以搭配 PageController 來處理,做到我們要的效果。
  • PageController
    讓我們可以操作 PageView 中每一頁的顯示,例如:讓我們在根據畫面(viewport size 的增量)控制 offset。 幾個重點屬性與方法:
    • initialPage:設定第一個要顯示的 Page;
    • keepPage:當 scrollable 被重建時回復到現在的 Page;
    • page:取得現在顯示的 Page;
    • animateToPage:設定動畫讓 PagView 從現在的 Page 移動到特定的 Page;
    • jumpToPage:設定移動到特定的 Page;
    • offset:代表 scrollable widget 目前 scroll 的 offset;其值為 double get offset => position.pixels; 這個值搭配 PageView 設定 scrollDirection 的方向代表不同的對象; offset 值會從 0 ~ Width*(N-1),例如:PageView 設定 width 是 200,有 3 個 Page,那就是 0 ~ 200*(3-1),每翻過一個新的 Page,offset 就加上 width 直到最後。
    PageController 的使用方式可簡單參考:Sample

接著參考 Flutter之使用PageView实现图片预览视差效果 做出如上圖在切換 Page 時做出 Page 在相同位置做淡入淡出效果。 裏面有提到幾個重點:

  • 需要 PageController 的 offset 來計算每個 Page 在移動距離佔整個畫面的比例:var pageOffset = controller.offset / width;(如果您的 Widget 是有固定的寬度,那就是 Page 移動距離佔指定寬度的比例)。 同上面所說 offset 代表是目前 PageController 移動的位置,需要去除以寬度才會知道當前的 Page 移動的比例;
  • 計算出目前移動時左邊的畫面(var currentLeftPageIndex = pageOffset.floor();)是誰,相反地如果您的滑動是垂直的話,那就是計算您上面的畫面是誰; 根據 Flutter之使用PageView实现图片预览视差效果 的介紹,翻頁時最多只能看到兩頁,例如:第 0 頁翻到第 1 頁時,只會看到第 0 跟第 1 頁各一半,再翻過去後只剩下第 1 頁。所以計算 currentLeftPageIndex 再翻頁時就是第 0 頁以此類推。
  • var currentPageOffsetPercent = pageOffset - currentLeftPageIndex; 利用當前頁的 offset 減去左邊頁來得到當前左頁的偏移值,值在 0 ~ 1 之間。代表從第 0 頁翻到第 1 頁中間變量的過程,這個值也是用來做淡入淡出效果的依據。
  • 因爲要固定 Page 的位置做淡入淡出效果,所以要設定被翻出的 Page 為負數的偏移值,如:(pageOffset - index) * width

利用完整的程式碼來說明:

PageController _controller;
var pageOffset = 0.0;
var screenWidth = 0.0;
var images = [
  'http://0rz.tw/y2yyL',
  'http://0rz.tw/RTyOI',
  'http://0rz.tw/GZfs8',
  'http://0rz.tw/myTYd',
  'http://0rz.tw/HuKP2'
];

@override
void initState() {
  super.initState();
  _controller = PageController(initialPage: 0);
  // 監聽 PageController 的 Scroller 變化
  _controller.addListener(_offsetChanged);
}

void _offsetChanged() {
  // 每次的移動都重新計算對應的偏移值與特效
  setState(() {
    pageOffset = _controller.offset / screenWidth;
  });
}

@override
Widget build(BuildContext context) {
  // 預設使用螢幕的寬度
  screenWidth = MediaQuery.of(context).size.width;

  return Scaffold(
    appBar: AppBar(
      title: Text(widget.title),
    ),
    body: PageView.builder(
      controller: _controller,
      itemCount: images.length,
      itemBuilder: (context, index) {
        // 計算每次異動時左邊的 Page 是哪個 index
        var currentLeftPageIndex = pageOffset.floor();
        // 計算現在畫面 Offset 佔的比例
        var currentPageOffsetPercent = pageOffset - currentLeftPageIndex;
        // 加入移動的特效
        return Transform.translate(
          // 因爲是水平滑動,所以設定 offset 的 X 值,因爲 Page 固定不動
          // 所以要先用 pageOffset 減去 index 得到 負數
          // 如果是垂直滑動,請設定 offset 的 Y 值
          offset: Offset((pageOffset - index) * screenWidth, 0),
                 // 加入調整透明度效果
          child: Opacity(
                 // 如果現在左邊的 index 等於正要建立的 index,則讓它透明度變淡,因爲它要退出畫面了
                 // 相反地是要顯示,則使用原本的 currentPageOffsetPercent
            opacity: currentLeftPageIndex == index
                ? 1 - currentPageOffsetPercent
                : currentPageOffsetPercent,
            child: Container(
                decoration: BoxDecoration(
                    image: DecorationImage(
                        image: NetworkImage(images[index]),
                        fit: BoxFit.cover))),
          ),
        );
      },
    ));
}

效果如下圖: 想要加入 indicator 顯示 PageView 到哪一個 Page 呢? 可以利用 PageView example with dots indicator 的範例,搭配上方建立的 PageController 來配合,所以調整程式碼把 PageView example with dots indicator 建立的 indicator widget 加入到上方範例:

Stack(
  alignment: Alignment.topCenter,
  children: [
    PageView.builder(
      controller: _controller,
      itemCount: images.length,
      itemBuilder: (context, index) {
                ...
      },),
    // 加入 indicator, 並使用相同的 PageController
    DotsIndicator(
      color: Colors.white,
      itemCount: images.length,
      controller: _controller,
    )
  ],));

上方程式利用 Stack 堆疊兩個 widget 做出效果,並在 DotIndicator 使用 PageController 顯示的是那一個 index:

double selectedness = Curves.easeOut.transform(
  max(
    0.0,
    1.0 - ((controller.page ?? controller.initialPage) - index).abs(),
  ),);

另外,如果要做到每個 Page 翻頁時,聯動另一個 ListView 做切換,要怎麼做呢? 需要搭配其他 Widgets:

  • IgnorePointer
    用來包裝 Widget 忽略其原有的事件。 例如:範例需要一個 ListView,它本身有 scroll 功能,不應該跟 PageView 衝突,所以需要它來包裝;
  • ScrollController
    負責保存 scroll 狀態,讓具有 scroll 功能的 widget 可以移動或紀錄目前的位置。 例如:利用 IgnorePointer 取消了 ListView 的互動,因此需要加入它來移動到指定的項目;

利用程式碼來說明:

final ScrollController _scrollController = ScrollController();
var texts = ['GOW 1', 'GOW 2', 'GOW 3', 'GEARS OF WAR JUDGMENT', 'GEARS OF WAR:ULTIMATE EDITION'];
void _offsetChanged() {
  setState(() {
    // 利用 PageController.offset 來移動
    _scrollController.jumpTo(_controller.offset);
  });
}

Scaffold(
  appBar: AppBar(
    title: Text(widget.title)),
    body: Stack(
        alignment: AlignmentDirectional.bottomStart,
        children: [
            PageView.builder(
               .....  
            ),
            // Indicator
            Padding(
                padding: const EdgeInsets.only(bottom: 30),
                child: DotsIndicator(
                  color: Colors.white,
                  itemCount: images.length,
                  controller: _controller,
                )),
            // 利用 IgnorePointer 忽略 ListView 的滑動
            IgnorePointer(
              child: ListView.builder(
              // 改利用 ScrollController 來操作 ListView
              controller: _scrollController,
              scrollDirection: Axis.horizontal,
              itemCount: texts.length,
              itemBuilder: (context, index) {
                return Container(
                    alignment: Alignment.bottomLeft,
                    // 設定 width 與 Page 一致
                    width: MediaQuery.of(context).size.width,
                    padding: const EdgeInsets.only(left: 10, bottom: 50),
                    child: Text(
                      texts[index],
                      style: TextStyle(fontSize: 20, color: Colors.white),
                    ));
              },
          ))
      ],
));

_scrollController.jumpTo(_controller.offset); 代表 ListView 原理跟 PageView 一樣,offset 代表目前 scroll 移動的距離,所以需要把每一個 Item 的寬度設定跟 Page 一樣(螢慕寬度)。 效果如下: 範例程式,可以到 pageviwer_sample 下載,如果有問題歡迎直接開 issues 給我。

======

開始學習 Flutter 順手記錄開發時遇到的問題與找到的解決方法,盡可能用自己的話説明。希望對大家有所幫助,謝謝。

References