22、Flutter Widgets 之 WillPopScope返回拦截

931 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第22天,点击查看活动详情

概述:

为了避免用户误触返回按钮而导致 App 退出,在很多 App 中都拦截了用户点击返回键的按钮,然后进行一些防误触判断,比如当用户在某一个时间段内点击两次时,才会认为用户是要退出(而非误触)。Flutter中可以通过WillPopScope来实现返回按钮拦截,

WillPopScope的默认构造函数:

const WillPopScope({
  ...
  required WillPopCallback onWillPop,
  required Widget child
})

onWillPop是一个回调函数,当用户点击返回按钮时被调用(包括导航返回按钮及Android物理返回按钮)。该回调需要返回一个Future对象,如果返回的Future最终值为false时,则当前路由不出栈(不会返回);最终值为true时,当前路由出栈退出。我们需要提供这个回调来决定是否退出。

双击退出

为了防止用户误触返回键退出,我们拦截返回事件。当用户在1秒内点击两次返回按钮时,则退出;如果间隔超过1秒则不退出,并重新记时。代码如下:

WillPopScope(
        child: Container(
          alignment: Alignment.center,
          child: Text("1秒内连续按两次返回键退出"),
        ),
        onWillPop: () async {
          if (_lastPressedAt != null ||
              DateTime.now().difference(_lastPressedAt!) >
                  Duration(seconds: 1)) {
            //两次点击间隔超过1秒则重新计时
            _lastPressedAt = DateTime.now();
            print('false');
            return false;
          }
          print('true');
 
          return true;
        });

App中有多个Navigator

我们的App通常是在MaterialApp和CupertinoApp下,MaterialApp和CupertinoApp本身有一个Navigator,所以默认情况下调用Navigator.pop或者Navigator.push就是在操作此Navigator。不过在一些情况下,我们希望有自己定义的Navigator,比如如下场景:

  • 在页面底部有一个常驻bar,其上展示内容,这个常驻bar就需要一个自己的Navigator。
  • 在使用TabView、BottomNavigationBar、CupertinoTabView这些组件时,希望有多个Tab,但每个Tab中有自己的导航行为,这时需要给每一个Tab加一个Navigator。

如首页:

class MyHomePage1 extends StatefulWidget {
  MyHomePage1({required Key key, required this.title}) : super(key: key);
 
  final String title;
 
  @override
  _MyHomePage1State createState() => _MyHomePage1State();
}
 
class _MyHomePage1State extends State<MyHomePage1> {
 
  GlobalKey<NavigatorState> _key = GlobalKey();
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: getAppBar('Navigator'),
      body: WillPopScope(
          onWillPop: () async {
            if (_key.currentState!.canPop()) {
              _key.currentState!.pop();
              return false;
            }
            return true;
          },
          child: Column(
            children: <Widget>[
              Expanded(
                child: Navigator(
                  key: _key,
                  onGenerateRoute: (RouteSettings settings) =>
                      MaterialPageRoute(builder: (context) {
                        return OnePage();
                      }),
                ),
              ),
              Container(
                height: 64,
                color: Colors.blue,
                alignment: Alignment.center,
                child: Text('底部Bar'),
              )
            ],
          )),
    );
  }
}
class OnePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          child: ElevatedButton(
            child: Text('去下一个页面'),
            onPressed: () {
              Navigator.push(context, MaterialPageRoute(builder: (context) {
                return TwoPage();
              }));
            },
          ),
        ),
      ),
    );
  }
}
class TwoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          child: Text('这是第二个页面'),
        ),
      ),
    );
  }
}

图片.png

手势返回

WillPopScope不支持手势返回,iOSCupertinoPageRoute不支持哪些情况,通过源码可知,当页面处于首屏

static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
  // If there's nothing to go back to, then obviously we don't support
  // the back gesture.
  if (route.isFirst)
    return false;
  // If the route wouldn't actually pop if we popped it, then the gesture
  // would be really confusing (or would skip internal routes), so disallow it.
  if (route.willHandlePopInternally)
    return false;
  // If attempts to dismiss this route might be vetoed such as in a page
  // with forms, then do not allow the user to dismiss the route with a swipe.
  if (route.hasScopedWillPopCallback)
    return false;
  // Fullscreen dialogs aren't dismissible by back swipe.
  if (route.fullscreenDialog)
    return false;
  // If we're in an animation already, we cannot be manually swiped.
  if (route.animation!.status != AnimationStatus.completed)
    return false;
  // If we're being popped into, we also cannot be swiped until the pop above
  // it completes. This translates to our secondary animation being
  // dismissed.
  if (route.secondaryAnimation!.status != AnimationStatus.dismissed)
    return false;
  // If we're in a gesture already, we cannot start another.
  if (isPopGestureInProgress(route))
    return false;

  // Looks like a back gesture would be welcome!
  return true;
}

按钮返回可以通过WillPopScope处理,手势返回如何监听处理呢? 这里使用NotificationListener监听PageView一旦滑动到边界就主动pop页面,同时还要修改它的physicsClampingScrollPhysics因为iOS默认不会回调OverscrollNotification

关于手势返回的初步方式,想办法对手势进行监听并做响应的响应:

class SwipeLeftReturnWidget extends StatelessWidget {
  final Widget child;
  bool _isOverscroll = false;
  SwipeLeftReturnWidget(this.child);


  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollNotification>(
        child: child,
        onNotification: (notification){
          print(notification.runtimeType);

          switch(notification.runtimeType){

            case ScrollUpdateNotification:
              _isOverscroll = false;
              break;
            case ScrollEndNotification:
              if(_isOverscroll){
                //返回上一页
                Navigator.of(context).pop();
              }
              break;
            case OverscrollNotification:
              if(notification.depth == 0 && notification.metrics.extentBefore <= 0){
                //处于第一个tab并且继续左滑
                _isOverscroll = true;
              }
              break;
            default:
              break;
          }
          return false;
        },
    );
  }
}

总结:

本篇主要介绍了WillPopScope对导航返回事件的拦截处理,如业务界面正在编辑过程发送误触,或者操作中点击退出的一些询问处理等,此外还有手势返回的监听和相关处理等。