From 25980d80a96fab7e324cd61d4a3b13ba3e0bf099 Mon Sep 17 00:00:00 2001 From: bggRGjQaUbCoE Date: Sun, 19 Jan 2025 21:37:55 +0800 Subject: [PATCH] mod: main: use tabbarview Signed-off-by: bggRGjQaUbCoE --- lib/common/widgets/tabs.dart | 356 +++++++++++++++++++++++++++++++++ lib/pages/main/controller.dart | 2 +- lib/pages/main/view.dart | 17 +- 3 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 lib/common/widgets/tabs.dart diff --git a/lib/common/widgets/tabs.dart b/lib/common/widgets/tabs.dart new file mode 100644 index 00000000..17a5340e --- /dev/null +++ b/lib/common/widgets/tabs.dart @@ -0,0 +1,356 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart' hide TabBarView; + +/// A page view that displays the widget which corresponds to the currently +/// selected tab. +/// +/// This widget is typically used in conjunction with a [TabBar]. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=POtoEH-5l40} +/// +/// If a [TabController] is not provided, then there must be a [DefaultTabController] +/// ancestor. +/// +/// The tab controller's [TabController.length] must equal the length of the +/// [children] list and the length of the [TabBar.tabs] list. +/// +/// To see a sample implementation, visit the [TabController] documentation. +class CustomTabBarView extends StatefulWidget { + /// Creates a page view with one child per tab. + /// + /// The length of [children] must be the same as the [controller]'s length. + const CustomTabBarView({ + super.key, + required this.children, + this.controller, + this.physics, + this.dragStartBehavior = DragStartBehavior.start, + this.viewportFraction = 1.0, + this.clipBehavior = Clip.hardEdge, + this.scrollDirection = Axis.horizontal, + }); + + /// This widget's selection and animation state. + /// + /// If [TabController] is not provided, then the value of [DefaultTabController.of] + /// will be used. + final TabController? controller; + + /// One widget per tab. + /// + /// Its length must match the length of the [TabBar.tabs] + /// list, as well as the [controller]'s [TabController.length]. + final List children; + + /// How the page view should respond to user input. + /// + /// For example, determines how the page view continues to animate after the + /// user stops dragging the page view. + /// + /// The physics are modified to snap to page boundaries using + /// [PageScrollPhysics] prior to being used. + /// + /// Defaults to matching platform conventions. + final ScrollPhysics? physics; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.pageview.viewportFraction} + final double viewportFraction; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + final Axis scrollDirection; + + @override + State createState() => _CustomTabBarViewState(); +} + +class _CustomTabBarViewState extends State { + TabController? _controller; + PageController? _pageController; + late List _childrenWithKey; + int? _currentIndex; + int _warpUnderwayCount = 0; + int _scrollUnderwayCount = 0; + bool _debugHasScheduledValidChildrenCountCheck = false; + + // If the TabBarView is rebuilt with a new tab controller, the caller should + // dispose the old one. In that case the old controller's animation will be + // null and should not be accessed. + bool get _controllerIsValid => _controller?.animation != null; + + void _updateTabController() { + final TabController? newController = + widget.controller ?? DefaultTabController.maybeOf(context); + assert(() { + if (newController == null) { + throw FlutterError( + 'No TabController for ${widget.runtimeType}.\n' + 'When creating a ${widget.runtimeType}, you must either provide an explicit ' + 'TabController using the "controller" property, or you must ensure that there ' + 'is a DefaultTabController above the ${widget.runtimeType}.\n' + 'In this case, there was neither an explicit controller nor a default controller.', + ); + } + return true; + }()); + + if (newController == _controller) { + return; + } + + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + } + _controller = newController; + if (_controller != null) { + _controller!.animation!.addListener(_handleTabControllerAnimationTick); + } + } + + void _jumpToPage(int page) { + _warpUnderwayCount += 1; + _pageController!.jumpToPage(page); + _warpUnderwayCount -= 1; + } + + Future _animateToPage( + int page, { + required Duration duration, + required Curve curve, + }) async { + _warpUnderwayCount += 1; + await _pageController! + .animateToPage(page, duration: duration, curve: curve); + _warpUnderwayCount -= 1; + } + + @override + void initState() { + super.initState(); + _updateChildren(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateTabController(); + _currentIndex = _controller!.index; + if (_pageController == null) { + _pageController = PageController( + initialPage: _currentIndex!, + viewportFraction: widget.viewportFraction, + ); + } else { + _pageController!.jumpToPage(_currentIndex!); + } + } + + @override + void didUpdateWidget(CustomTabBarView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _updateTabController(); + _currentIndex = _controller!.index; + _jumpToPage(_currentIndex!); + } + if (widget.viewportFraction != oldWidget.viewportFraction) { + _pageController?.dispose(); + _pageController = PageController( + initialPage: _currentIndex!, + viewportFraction: widget.viewportFraction, + ); + } + // While a warp is under way, we stop updating the tab page contents. + // This is tracked in https://github.com/flutter/flutter/issues/31269. + if (widget.children != oldWidget.children && _warpUnderwayCount == 0) { + _updateChildren(); + } + } + + @override + void dispose() { + if (_controllerIsValid) { + _controller!.animation!.removeListener(_handleTabControllerAnimationTick); + } + _controller = null; + _pageController?.dispose(); + // We don't own the _controller Animation, so it's not disposed here. + super.dispose(); + } + + void _updateChildren() { + _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); + } + + void _handleTabControllerAnimationTick() { + if (_scrollUnderwayCount > 0 || !_controller!.indexIsChanging) { + return; + } // This widget is driving the controller's animation. + + if (_controller!.index != _currentIndex) { + _currentIndex = _controller!.index; + _warpToCurrentIndex(); + } + } + + void _warpToCurrentIndex() { + if (!mounted || _pageController!.page == _currentIndex!.toDouble()) { + return; + } + + final bool adjacentDestination = + (_currentIndex! - _controller!.previousIndex).abs() == 1; + if (adjacentDestination) { + _warpToAdjacentTab(_controller!.animationDuration); + } else { + _warpToNonAdjacentTab(_controller!.animationDuration); + } + } + + Future _warpToAdjacentTab(Duration duration) async { + if (duration == Duration.zero) { + _jumpToPage(_currentIndex!); + } else { + await _animateToPage(_currentIndex!, + duration: duration, curve: Curves.ease); + } + if (mounted) { + setState(() { + _updateChildren(); + }); + } + return Future.value(); + } + + Future _warpToNonAdjacentTab(Duration duration) async { + final int previousIndex = _controller!.previousIndex; + assert((_currentIndex! - previousIndex).abs() > 1); + + // initialPage defines which page is shown when starting the animation. + // This page is adjacent to the destination page. + final int initialPage = _currentIndex! > previousIndex + ? _currentIndex! - 1 + : _currentIndex! + 1; + + setState(() { + // Needed for `RenderSliverMultiBoxAdaptor.move` and kept alive children. + // For motivation, see https://github.com/flutter/flutter/pull/29188 and + // https://github.com/flutter/flutter/issues/27010#issuecomment-486475152. + _childrenWithKey = List.of(_childrenWithKey, growable: false); + final Widget temp = _childrenWithKey[initialPage]; + _childrenWithKey[initialPage] = _childrenWithKey[previousIndex]; + _childrenWithKey[previousIndex] = temp; + }); + + // Make a first jump to the adjacent page. + _jumpToPage(initialPage); + + // Jump or animate to the destination page. + if (duration == Duration.zero) { + _jumpToPage(_currentIndex!); + } else { + await _animateToPage(_currentIndex!, + duration: duration, curve: Curves.ease); + } + + if (mounted) { + setState(() { + _updateChildren(); + }); + } + } + + void _syncControllerOffset() { + _controller!.offset = + clampDouble(_pageController!.page! - _controller!.index, -1.0, 1.0); + } + + // Called when the PageView scrolls + bool _handleScrollNotification(ScrollNotification notification) { + if (_warpUnderwayCount > 0 || _scrollUnderwayCount > 0) { + return false; + } + + if (notification.depth != 0) { + return false; + } + + if (!_controllerIsValid) { + return false; + } + + _scrollUnderwayCount += 1; + final double page = _pageController!.page!; + if (notification is ScrollUpdateNotification && + !_controller!.indexIsChanging) { + final bool pageChanged = (page - _controller!.index).abs() > 1.0; + if (pageChanged) { + _controller!.index = page.round(); + _currentIndex = _controller!.index; + } + _syncControllerOffset(); + } else if (notification is ScrollEndNotification) { + _controller!.index = page.round(); + _currentIndex = _controller!.index; + if (!_controller!.indexIsChanging) { + _syncControllerOffset(); + } + } + _scrollUnderwayCount -= 1; + + return false; + } + + bool _debugScheduleCheckHasValidChildrenCount() { + if (_debugHasScheduledValidChildrenCountCheck) { + return true; + } + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + _debugHasScheduledValidChildrenCountCheck = false; + if (!mounted) { + return; + } + assert(() { + if (_controller!.length != widget.children.length) { + throw FlutterError( + "Controller's length property (${_controller!.length}) does not match the " + "number of children (${widget.children.length}) present in TabBarView's children property.", + ); + } + return true; + }()); + }, debugLabel: 'TabBarView.validChildrenCountCheck'); + _debugHasScheduledValidChildrenCountCheck = true; + return true; + } + + @override + Widget build(BuildContext context) { + assert(_debugScheduleCheckHasValidChildrenCount()); + + return NotificationListener( + onNotification: _handleScrollNotification, + child: PageView( + scrollDirection: widget.scrollDirection, + dragStartBehavior: widget.dragStartBehavior, + clipBehavior: widget.clipBehavior, + controller: _pageController, + physics: widget.physics == null + ? const PageScrollPhysics().applyTo(const ClampingScrollPhysics()) + : const PageScrollPhysics().applyTo(widget.physics), + children: _childrenWithKey, + ), + ); + } +} diff --git a/lib/pages/main/controller.dart b/lib/pages/main/controller.dart index f53cd6bd..10fbe999 100644 --- a/lib/pages/main/controller.dart +++ b/lib/pages/main/controller.dart @@ -23,7 +23,7 @@ class MainController extends GetxController { final StreamController bottomBarStream = StreamController.broadcast(); late bool hideTabBar; - late PageController pageController; + late TabController controller; RxInt selectedIndex = 0.obs; RxBool isLogin = false.obs; diff --git a/lib/pages/main/view.dart b/lib/pages/main/view.dart index 6effec72..e5be4099 100644 --- a/lib/pages/main/view.dart +++ b/lib/pages/main/view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:PiliPlus/common/widgets/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/tabs.dart'; import 'package:PiliPlus/grpc/grpc_client.dart'; import 'package:PiliPlus/pages/mine/controller.dart'; import 'package:PiliPlus/utils/utils.dart'; @@ -41,8 +42,11 @@ class _MainAppState extends State void initState() { super.initState(); _lastSelectTime = DateTime.now().millisecondsSinceEpoch; - _mainController.pageController = - PageController(initialPage: _mainController.selectedIndex.value); + _mainController.controller = TabController( + vsync: this, + initialIndex: _mainController.selectedIndex.value, + length: _mainController.navigationBars.length, + ); enableMYBar = GStorage.setting.get(SettingBoxKey.enableMYBar, defaultValue: true); useSideBar = @@ -110,7 +114,7 @@ class _MainAppState extends State if (value != _mainController.selectedIndex.value) { _mainController.selectedIndex.value = value; - _mainController.pageController.jumpToPage(value); + _mainController.controller.animateTo(value); dynamic currentPage = _mainController.pages[value]; if (currentPage is HomePage) { _checkDefaultSearch(); @@ -213,9 +217,12 @@ class _MainAppState extends State color: Theme.of(context).colorScheme.outline.withOpacity(0.06), ), Expanded( - child: PageView( + child: CustomTabBarView( + scrollDirection: context.orientation == Orientation.portrait + ? Axis.horizontal + : Axis.vertical, physics: const NeverScrollableScrollPhysics(), - controller: _mainController.pageController, + controller: _mainController.controller, children: _mainController.pages, ), ),