Flutter 三種方式實現頁面切換後保持原頁面狀態

前言:

在Flutter應用中,導航欄切換頁面後默認狀況下會丟失原頁面狀態,即每次進入頁面時都會從新初始化狀態,若是在initState中打印日誌,會發現每次進入時都會輸出,顯然這樣增長了額外的開銷,而且帶來了很差的用戶體驗。markdown

在正文以前,先看一些常見的App導航,以喜馬拉雅FM爲例:app

0.gif

它擁有一個固定的底部導航以及首頁的頂部導航,能夠看到無論是點擊底部導航切換頁面仍是在首頁左右側滑切換頁面,以前的頁面狀態都是始終維持的,下面就具體介紹下如何在flutter中實現相似喜馬拉雅的導航效果ide

第一步:實現固定的底部導航

在經過flutter create生成的項目模板中,咱們先簡化一下代碼,將MyHomePage提取到一個單獨的home.dart文件,並在Scaffold腳手架中添加bottomNavigationBar底部導航,在body中展現當前選中的子頁面。工具

/// home.dart
import 'package:flutter/material.dart';

import './pages/first_page.dart';
import './pages/second_page.dart';
import './pages/third_page.dart';

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final items = [
    BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('首頁')),
    BottomNavigationBarItem(icon: Icon(Icons.music_video), title: Text('聽')),
    BottomNavigationBarItem(icon: Icon(Icons.message), title: Text('消息'))
  ];

  final bodyList = [FirstPage(), SecondPage(), ThirdPage()];

  int currentIndex = 0;

  void onTap(int index) {
    setState(() {
      currentIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('demo'),
        ),
        bottomNavigationBar: BottomNavigationBar(
            items: items,
            currentIndex: currentIndex, 
            onTap: onTap
        ),
        body: bodyList[currentIndex]
    );
  }
}


複製代碼

其中的三個子頁面結構相同,均顯示一個計數器和一個加號按鈕,以first_page.dart爲例:測試

/// first_page.dart
import 'package:flutter/material.dart';

class FirstPage extends StatefulWidget {
  @override
  _FirstPageState createState() => _FirstPageState();
}

class _FirstPageState extends State<FirstPage> {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
            child: Text('First: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        )
    );
  }
}

複製代碼

當前效果以下:優化

1.gif

能夠看到,從第二頁切換回第一頁時,第一頁的狀態已經丟失ui

第二步:實現底部導航切換時保持原頁面狀態

可能有些小夥伴在搜索後會開始直接使用官方推薦的AutomaticKeepAliveClientMixin,經過在子頁面的State類重寫wantKeepAlivetrue 。 然而,若是你的代碼和我上面的相似,body中並無使用PageViewTabBarView,很不幸的告訴你,踩到坑了,這樣是無效的,緣由後面再詳述。如今咱們先來介紹另外兩種方式:this

① 使用IndexedStack實現spa

IndexedStack繼承自Stack,它的做用是顯示第indexchild,其它child在頁面上是不可見的,但全部child的狀態都被保持,因此這個Widget能夠實現咱們的需求,咱們只須要將如今的bodyIndexedStack包裹一層便可日誌

/// home.dart
class _MyHomePageState extends State<MyHomePage> {
  ...
  ...
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('demo'),
        ),
        bottomNavigationBar: BottomNavigationBar(
            items: items, currentIndex: currentIndex, onTap: onTap),
        // body: bodyList[currentIndex]
        body: IndexedStack(
          index: currentIndex,
          children: bodyList,
        ));
  }
複製代碼

保存後再次測試一下

2.gif

② 使用Offstage實現

Offstage的做用十分簡單,經過一個參數來控制child是否顯示,因此咱們一樣能夠組合使用Offstage來實現該需求,其實現原理與IndexedStack相似

/// home.dart
class _MyHomePageState extends State<MyHomePage> {
  ...
  ...
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('demo'),
        ),
        bottomNavigationBar: BottomNavigationBar(
            items: items, currentIndex: currentIndex, onTap: onTap),
        // body: bodyList[currentIndex],
        body: Stack(
          children: [
            Offstage(
              offstage: currentIndex != 0,
              child: bodyList[0],
            ),
            Offstage(
              offstage: currentIndex != 1,
              child: bodyList[1],
            ),
            Offstage(
              offstage: currentIndex != 2,
              child: bodyList[2],
            ),
          ],
        ));
  }
}
複製代碼

在上面的兩種方式中均可以實現保持原頁面狀態的需求,但這裏有一些開銷上的問題,有經驗的小夥伴應該能發現當應用第一次加載的時候,全部子頁狀態都被實例化了(>這裏的細節並非由於我直接把子頁實例化放在bodyList裏...<),若是在子頁StateinitState中打印日誌,能夠在終端看到一次性輸出了全部子頁的日誌。下面就介紹另外一種經過繼承AutomaticKeepAliveClientMixin的方式來更好的實現保持狀態。

第三步:實現首頁的頂部導航

首先咱們經過配合使用TabBar+TabBarView+AutomaticKeepAliveClientMixin來實現頂部導航(注意:TabBarTabBarView須要提供controller,若是本身沒有定義,則必須使用DefaultTabController包裹)。此處也能夠選擇使用PageView,後面會介紹。

咱們先在home.dart文件移除Scaffold腳手架中的appBar頂部工具欄,而後開始重寫首頁first_page.dart:

/// first_page.dart
import 'package:flutter/material.dart';

import './recommend_page.dart';
import './vip_page.dart';
import './novel_page.dart';
import './live_page.dart';

class _TabData {
  final Widget tab;
  final Widget body;
  _TabData({this.tab, this.body});
}

final _tabDataList = <_TabData>[
  _TabData(tab: Text('推薦'), body: RecommendPage()),
  _TabData(tab: Text('VIP'), body: VipPage()),
  _TabData(tab: Text('小說'), body: NovelPage()),
  _TabData(tab: Text('直播'), body: LivePage())
];

class FirstPage extends StatefulWidget {
  @override
  _FirstPageState createState() => _FirstPageState();
}

class _FirstPageState extends State<FirstPage> {
  final tabBarList = _tabDataList.map((item) => item.tab).toList();
  final tabBarViewList = _tabDataList.map((item) => item.body).toList();

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        length: tabBarList.length,
        child: Column(
          children: <Widget>[
            Container(
              width: double.infinity,
              height: 80,
              padding: EdgeInsets.fromLTRB(20, 24, 0, 0),
              alignment: Alignment.centerLeft,
              color: Colors.black,
              child: TabBar(
                  isScrollable: true,
                  indicatorColor: Colors.red,
                  indicatorSize: TabBarIndicatorSize.label,
                  unselectedLabelColor: Colors.white,
                  unselectedLabelStyle: TextStyle(fontSize: 18),
                  labelColor: Colors.red,
                  labelStyle: TextStyle(fontSize: 20),
                  tabs: tabBarList),
            ),
            Expanded(
                child: TabBarView(
              children: tabBarViewList,
              // physics: NeverScrollableScrollPhysics(), // 禁止滑動
            ))
          ],
        ));
  }
}

複製代碼

其中推薦頁、VIP頁、小說頁、直播頁的結構仍和以前的首頁結構相同,僅顯示一個計數器和一個加號按鈕,以推薦頁recommend_page.dart爲例:

/// recommend_page.dart
import 'package:flutter/material.dart';

class RecommendPage extends StatefulWidget {
  @override
  _RecommendPageState createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage> {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }
  
  @override
  void initState() {
    super.initState();
    print('recommend initState');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body:Center(
          child: Text('首頁推薦: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        ));
  }
}

複製代碼

保存後測試,

3.gif
能夠看到,如今添加了首頁頂部導航,且默認支持左右側滑,接下來再進一步的完善狀態保持

第四步:實現首頁頂部導航切換時保持原頁面狀態

③ 使用AutomaticKeepAliveClientMixin實現

寫到這裏已經很簡單了,咱們只須要在首頁導航內須要保持頁面狀態的子頁State中,繼承AutomaticKeepAliveClientMixin並重寫wantKeepAlivetrue便可。

notes:Subclasses must implement wantKeepAlive, and their build methods must call super.build (the return value will always return null, and should be ignored)

以首頁推薦recommend_page.dart爲例:

/// recommend_page.dart
import 'package:flutter/material.dart';

class RecommendPage extends StatefulWidget {
  @override
  _RecommendPageState createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage> with AutomaticKeepAliveClientMixin {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  bool get wantKeepAlive => true;

  @override
  void initState() {
    super.initState();
    print('recommend initState');
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Scaffold(
        body:Center(
          child: Text('首頁推薦: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        ));
  }
}


複製代碼

再次保存測試,

4.gif

如今已經能夠看到,無論是切換底部導航仍是切換首頁頂部導航,全部的頁面狀態均可以被保持,而且在應用第一次加載時,終端只看到recommend initState的日誌,第一次切換首頁頂部導航至vip頁面時,終端輸出vip initState,當再次返回推薦頁時,再也不輸出recommend initState

因此,使用TabBarView+AutomaticKeepAliveClientMixin這種方式既實現了頁面狀態的保持,又具備相似惰性求值的功能,對於未使用的頁面狀態不會進行實例化,減少了應用初始化時的開銷。

更新

前面在底部導航介紹了使用IndexedStackOffstage兩種方式實現保持頁面狀態,但它們的缺點在於第一次加載時便實例化了全部的子頁面State。爲了進一步優化,下面咱們使用PageView+AutomaticKeepAliveClientMixin重寫以前的底部導航,其中PageViewTabBarView的實現原理相似,具體選擇哪個並無強制要求。更新後的home.dart文件以下:

/// home.dart
import 'package:flutter/material.dart';

import './pages/first_page.dart';
import './pages/second_page.dart';
import './pages/third_page.dart';

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final items = [
    BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('首頁')),
    BottomNavigationBarItem(icon: Icon(Icons.music_video), title: Text('聽')),
    BottomNavigationBarItem(icon: Icon(Icons.message), title: Text('消息'))
  ];

  final bodyList = [FirstPage(), SecondPage(), ThirdPage()];

  final pageController = PageController();

  int currentIndex = 0;

  void onTap(int index) {
    pageController.jumpToPage(index);
  }

  void onPageChanged(int index) {
    setState(() {
      currentIndex = index;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        bottomNavigationBar: BottomNavigationBar(
            items: items, currentIndex: currentIndex, onTap: onTap),
        // body: bodyList[currentIndex],
        body: PageView(
          controller: pageController,
          onPageChanged: onPageChanged,
          children: bodyList,
          physics: NeverScrollableScrollPhysics(), // 禁止滑動
        ));
  }
}

複製代碼

而後在bodyList的子頁State中繼承AutomaticKeepAliveClientMixin並重寫wantKeepAlive,以second_page.dart爲例:

/// second_page.dart
import 'package:flutter/material.dart';

class SecondPage extends StatefulWidget {
  @override
  _SecondPageState createState() => _SecondPageState();
}

class _SecondPageState extends State<SecondPage> with AutomaticKeepAliveClientMixin {
  int count = 0;

  void add() {
    setState(() {
      count++;
    });
  }

  @override
  bool get wantKeepAlive => true;
  
  @override
  void initState() {
    super.initState();
    print('second initState');
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Scaffold(
        body: Center(
          child: Text('Second: $count', style: TextStyle(fontSize: 30))
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: add,
          child: Icon(Icons.add),
        ));
  }
}

複製代碼

Ok,更新後保存運行,應用第一次加載時不會輸出second initState,僅當第一次點擊底部導航切換至該頁時,該子頁的State被實例化。

至此,如何實現一個相似的 底部 + 首頁頂部導航 完結 ~

相關文章
相關標籤/搜索