From 5b62253225f24639422e5583853bcb72836ad6c3 Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Tue, 16 Jul 2024 20:49:45 +0700 Subject: [PATCH 1/8] first commit --- example/lib/main.dart | 5 +- lib/src/navbar_notifier.dart | 2 +- lib/src/navbar_router.dart | 232 +++++++++++++++++++++++++++++++++-- lib/src/page_view.dart | 0 4 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 lib/src/page_view.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index afd1a6b..230bdd0 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -399,7 +399,7 @@ class FeedTile extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - height: 300, + height: 400, margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8), color: Theme.of(context).colorScheme.surface, child: Card( @@ -517,6 +517,8 @@ class _ProductListState extends ConsumerState { title: const Text('Products'), ), body: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, controller: _scrollController, itemBuilder: (context, index) { return Padding( @@ -552,6 +554,7 @@ class ProductTile extends StatelessWidget { return Card( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), + width: MediaQuery.of(context).size.width, height: 120, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/src/navbar_notifier.dart b/lib/src/navbar_notifier.dart index 693a553..5bb086a 100644 --- a/lib/src/navbar_notifier.dart +++ b/lib/src/navbar_notifier.dart @@ -108,7 +108,7 @@ class NavbarNotifier extends ChangeNotifier { if (behavior == BackButtonBehavior.rememberHistory) { if (_navbarStackHistory.length > 1) { _navbarStackHistory.removeLast(); - _index = _navbarStackHistory.last; + index = _navbarStackHistory.last; _singleton.notify(); exitingApp = false; } else { diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index 774ee78..04a69b3 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -1,6 +1,10 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first + import 'package:badges/badges.dart' as badges; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; + import 'package:navbar_router/navbar_router.dart'; part 'animated_navbar.dart'; @@ -216,6 +220,46 @@ class _NavbarRouterState extends State NavbarNotifier.makeBadgeVisible(NavbarNotifier.currentIndex, true); initAnimation(); NavbarNotifier.index = widget.initialIndex; + _pageController = ScrollController(); + _pageController.addListener( + () { + // if (!mounted) return; + // print(_pageController.offset); + + // var page = getPageFromPixels(context); + // // print(page); + // if ((page.round() - page).abs() < 0.2 && + // page.round() != NavbarNotifier.currentIndex) { + // // if ((page.round() - NavbarNotifier.currentIndex).abs() == 1) { + // // if ((page - NavbarNotifier.currentIndex).abs() > 0.3) return; + // // } + // int value = page.round(); + // NavbarNotifier.index = value; + // if (widget.onChanged != null) { + // widget.onChanged!(value); + // } + // _handleFadeAnimation(); + // } + }, + ); + NavbarNotifier.addIndexChangeListener( + (newIndex) { + if (!mounted) return; + // print(newIndex); + + _pageController.animateTo(getPixelsFromPage(newIndex), + duration: Durations.medium1, curve: Curves.ease); + }, + ); + } + + double getPageFromPixels(context) { + return _pageController.offset / + (MediaQuery.of(context).size.width - getPadding()); + } + + double getPixelsFromPage(int page) { + return (MediaQuery.of(context).size.width - getPadding()) * page; } void updateWidget() { @@ -231,7 +275,7 @@ class _NavbarRouterState extends State fadeAnimation = items.map((NavbarItem item) { return AnimationController( vsync: this, - value: item == items[widget.initialIndex] ? 1.0 : 0.0, + value: item == items[widget.initialIndex] ? 1.0 : 1, duration: Duration(milliseconds: widget.destinationAnimationDuration)); }).toList(); @@ -336,6 +380,10 @@ class _NavbarRouterState extends State } } + int pageViewIndex = 0; + late ScrollController _pageController; + bool dragging = false; + @override Widget build(BuildContext context) { return PopScope( @@ -358,10 +406,53 @@ class _NavbarRouterState extends State /// same duration as [_AnimatedNavbar]'s animation duration duration: const Duration(milliseconds: 500), padding: EdgeInsets.only(left: getPadding()), - child: Stack(children: [ - for (int i = 0; i < NavbarNotifier.length; i++) - _buildIndexedStackItem(i, context) - ]), + child: ListView.builder( + itemCount: NavbarNotifier.length, + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + controller: _pageController, + itemBuilder: (context, i) { + return KeepAliveWrapper( + child: NotificationListener( + onNotification: (OverscrollNotification value) { + // print(value.metrics.pixels); + if (value.overscroll < 0 && + _pageController.offset + + value.overscroll <= + 0) { + if (_pageController.offset != 0) { + _pageController.jumpTo(0); + } + return true; + } + if (_pageController.offset + + value.overscroll >= + _pageController + .position.maxScrollExtent) { + if (_pageController.offset != + _pageController + .position.maxScrollExtent) { + _pageController.jumpTo(_pageController + .position.maxScrollExtent); + } + return true; + } + _pageController.jumpTo( + _pageController.offset + + value.overscroll); + return true; + }, + child: Container( + // color: + // Colors.blueAccent.withOpacity(0.5), + width: MediaQuery.of(context).size.width - + getPadding(), + child: _buildIndexedStackItem(i, context))), + ); + }), + // Stack(children: [ + + // ]), ), Positioned( left: 0, @@ -387,21 +478,144 @@ class _NavbarRouterState extends State NavbarNotifier.currentIndex, false); } } else { - NavbarNotifier.index = x; - if (widget.onChanged != null) { - widget.onChanged!(x); + // NavbarNotifier.index = x; + // if (widget.onChanged != null) { + // widget.onChanged!(x); + // } + // _handleFadeAnimation(); + if ((x - NavbarNotifier.currentIndex).abs() > 1) { + _pageController.jumpTo( + getPixelsFromPage(x), + ); + } else { + _pageController.animateTo(getPixelsFromPage(x), + duration: Durations.extralong1, + curve: Curves.ease); } - _handleFadeAnimation(); } }, menuItems: items), ), + Positioned( + top: 50, + width: 50, + height: MediaQuery.of(context).size.height * 0.8, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (details) { + onDragStart(details); + }, + onHorizontalDragUpdate: (details) { + onDragUpdate(details); + }, + onHorizontalDragEnd: (details) { + onDragEnd(details); + }, + child: Container( + color: Colors.blueAccent.withOpacity(0.5), + ), + ), + ), + Positioned( + top: 50, + right: 0, + width: 50, + height: MediaQuery.of(context).size.height * 0.8, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: (details) { + onDragStart(details); + }, + onHorizontalDragUpdate: (details) { + onDragUpdate(details); + }, + onHorizontalDragEnd: (details) { + onDragEnd(details); + }, + child: Container( + color: Colors.blueAccent.withOpacity(0.5), + ), + ), + ) ], ); })); } + + onDragStart(details) { + if (dragging) return; + if (details.localPosition.dx < 100 || + details.localPosition.dx > MediaQuery.of(context).size.width - 100) { + dragging = true; + } + } + + onDragUpdate(DragUpdateDetails details) { + print(details.delta); + if (dragging) { + if (!mounted) return; + + var page = getPageFromPixels(context); + // print(page); + if ((page == 0 && details.delta.dx > 0.1) || + (page >= NavbarNotifier.length - 1 && details.delta.dx < -0.1)) { + return; + } + _pageController.jumpTo(_pageController.offset - details.delta.dx); + } + } + + onDragEnd(details) { + if (!mounted) { + dragging = false; + return; + } + print(_pageController.offset); + + var page = getPageFromPixels(context); + print(page); + int value = page.round(); + if (value < 0) { + _pageController.animateTo(_pageController.position.minScrollExtent, + duration: Durations.long1, curve: Curves.ease); + } else if (value >= NavbarNotifier.length) { + _pageController.animateTo(_pageController.position.maxScrollExtent, + duration: Durations.long1, curve: Curves.ease); + } else { + NavbarNotifier.index = value; + if (widget.onChanged != null) { + widget.onChanged!(value); + } + _handleFadeAnimation(); + } + + dragging = false; + } } final NavbarNotifier _navbarNotifier = NavbarNotifier(); List colors = [mediumPurple, Colors.orange, Colors.teal]; const Color mediumPurple = Color.fromRGBO(79, 0, 241, 1.0); + +class KeepAliveWrapper extends StatefulWidget { + final Widget child; + const KeepAliveWrapper({ + Key? key, + required this.child, + }) : super(key: key); + @override + KeepAliveWrapperState createState() => KeepAliveWrapperState(); +} + +class KeepAliveWrapperState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + // Setting to true will force the tab to never be disposed. This could be dangerous. + @override + bool get wantKeepAlive => true; +} diff --git a/lib/src/page_view.dart b/lib/src/page_view.dart new file mode 100644 index 0000000..e69de29 From e41ae5031bd7e42caacd53828afc2b7aca304c3e Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Tue, 16 Jul 2024 23:41:18 +0700 Subject: [PATCH 2/8] finish base feature --- example/lib/main.dart | 4 ++- lib/src/navbar_notifier.dart | 5 ++++ lib/src/navbar_router.dart | 47 ++++++++++++------------------------ 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 230bdd0..2f79d94 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -521,7 +521,9 @@ class _ProductListState extends ConsumerState { itemCount: 5, controller: _scrollController, itemBuilder: (context, index) { - return Padding( + return Container( + height: 200, + color: Colors.redAccent.shade100, padding: const EdgeInsets.all(8.0), child: InkWell( onTap: () { diff --git a/lib/src/navbar_notifier.dart b/lib/src/navbar_notifier.dart index 5bb086a..9e46946 100644 --- a/lib/src/navbar_notifier.dart +++ b/lib/src/navbar_notifier.dart @@ -75,6 +75,9 @@ class NavbarNotifier extends ChangeNotifier { if (_navbarStackHistory.contains(x)) { _navbarStackHistory.remove(x); } + + // just my suggestion + hideBottomNavBar = false; _navbarStackHistory.add(x); _notifyIndexChangeListeners(x); _singleton.notify(); @@ -108,6 +111,8 @@ class NavbarNotifier extends ChangeNotifier { if (behavior == BackButtonBehavior.rememberHistory) { if (_navbarStackHistory.length > 1) { _navbarStackHistory.removeLast(); + + // fix issue #53 index = _navbarStackHistory.last; _singleton.notify(); exitingApp = false; diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index 04a69b3..74678e6 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -1,7 +1,6 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first +// ignore_for_file: public_member_api_docs, sort_constructors_first, avoid_print import 'package:badges/badges.dart' as badges; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -221,27 +220,7 @@ class _NavbarRouterState extends State initAnimation(); NavbarNotifier.index = widget.initialIndex; _pageController = ScrollController(); - _pageController.addListener( - () { - // if (!mounted) return; - // print(_pageController.offset); - - // var page = getPageFromPixels(context); - // // print(page); - // if ((page.round() - page).abs() < 0.2 && - // page.round() != NavbarNotifier.currentIndex) { - // // if ((page.round() - NavbarNotifier.currentIndex).abs() == 1) { - // // if ((page - NavbarNotifier.currentIndex).abs() > 0.3) return; - // // } - // int value = page.round(); - // NavbarNotifier.index = value; - // if (widget.onChanged != null) { - // widget.onChanged!(value); - // } - // _handleFadeAnimation(); - // } - }, - ); + NavbarNotifier.addIndexChangeListener( (newIndex) { if (!mounted) return; @@ -415,7 +394,7 @@ class _NavbarRouterState extends State return KeepAliveWrapper( child: NotificationListener( onNotification: (OverscrollNotification value) { - // print(value.metrics.pixels); + print(value.overscroll); if (value.overscroll < 0 && _pageController.offset + value.overscroll <= @@ -437,14 +416,20 @@ class _NavbarRouterState extends State } return true; } - _pageController.jumpTo( - _pageController.offset + - value.overscroll); + if (value.overscroll.abs() < 0.1) { + _pageController.jumpTo( + _pageController.offset + + value.overscroll); + } else { + _pageController.animateTo( + getPixelsFromPage( + NavbarNotifier.currentIndex), + duration: Durations.short2, + curve: Curves.ease); + } return true; }, - child: Container( - // color: - // Colors.blueAccent.withOpacity(0.5), + child: SizedBox( width: MediaQuery.of(context).size.width - getPadding(), child: _buildIndexedStackItem(i, context))), @@ -533,7 +518,7 @@ class _NavbarRouterState extends State onDragEnd(details); }, child: Container( - color: Colors.blueAccent.withOpacity(0.5), + color: Colors.blueAccent.withOpacity(0.3), ), ), ) From f358ec421a7163091a2d453a2aafd943efd025a4 Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Wed, 17 Jul 2024 11:28:04 +0700 Subject: [PATCH 3/8] add variables to control swipe + update example app --- example/lib/main.dart | 152 ++++++++++++++----- lib/src/navbar_router.dart | 225 +++++++++++++++++------------ lib/src/navbar_swipeable_utls.dart | 31 ++++ lib/src/page_view.dart | 0 test/navbar_router_test.dart | 19 +-- 5 files changed, 287 insertions(+), 140 deletions(-) create mode 100644 lib/src/navbar_swipeable_utls.dart delete mode 100644 lib/src/page_view.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 2f79d94..8ad076e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -102,13 +102,14 @@ class HomePage extends ConsumerStatefulWidget { } class _HomePageState extends ConsumerState { + bool swipeable = false; final Map> _routes = const { 0: { '/': HomeFeeds(), FeedDetail.route: FeedDetail(), }, 1: { - '/': ProductList(), + '/': ProductPage(), ProductDetail.route: ProductDetail(), ProductComments.route: ProductComments(), }, @@ -193,7 +194,7 @@ class _HomePageState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const SizedBox( - width: 100, + width: 50, ), FloatingActionButton.extended( heroTag: 'showSnackBar', @@ -223,6 +224,16 @@ class _HomePageState extends ConsumerState { setState(() {}); }, ), + FloatingActionButton( + heroTag: 'swipe', + child: Icon( + swipeable ? Icons.swipe : Icons.touch_app_outlined), + onPressed: () { + // Programmatically toggle the Navbar visibility + swipeable = !swipeable; + setState(() {}); + }, + ), FloatingActionButton( heroTag: 'darkmode', child: Icon(appSetting.isDarkMode @@ -241,6 +252,7 @@ class _HomePageState extends ConsumerState { }), body: Builder(builder: (context) { return NavbarRouter( + swipeable: swipeable, errorBuilder: (context) { return const Center(child: Text('Error 404')); }, @@ -459,6 +471,86 @@ class FeedDetail extends StatelessWidget { } } +class ProductPage extends ConsumerStatefulWidget { + const ProductPage({super.key}); + static const String route = '/'; + + @override + ConsumerState createState() => _ProductPageState(); +} + +class _ProductPageState extends ConsumerState { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Products'), + ), + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + // controller: _scrollController, + itemBuilder: (context, index) { + return Container( + height: 60, + color: Colors.redAccent.shade100, + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () { + if (index == 0) { + NavbarNotifier.pushNamed(FeedDetail.route, 0); + NavbarNotifier.showSnackBar( + context, 'switching to Home', onClosed: () { + NavbarNotifier.index = 0; + }); + } else { + NavbarNotifier.hideBottomNavBar = false; + Navigate.pushNamed(context, ProductDetail.route, + transitionType: TransitionType.scale, + arguments: {'id': index.toString()}); + } + }, + child: Card( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12), + height: 60, + width: 120, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.all(8), + height: 15, + width: 15, + color: Theme.of(context) + .colorScheme + .secondary, + ), + Flexible( + child: Text( + index != 0 + ? 'Product $index' + : 'Tap to push a route on HomePage Programmatically', + textAlign: TextAlign.end, + ), + ), + ], + )), + )), + ); + }), + ), + const Expanded(flex: 2, child: ProductList()), + ], + )); + } +} + class ProductList extends ConsumerStatefulWidget { const ProductList({super.key}); static const String route = '/'; @@ -512,38 +604,29 @@ class _ProductListState extends ConsumerState { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Products'), - ), - body: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 5, - controller: _scrollController, - itemBuilder: (context, index) { - return Container( - height: 200, - color: Colors.redAccent.shade100, - padding: const EdgeInsets.all(8.0), - child: InkWell( - onTap: () { - if (index == 0) { - NavbarNotifier.pushNamed(FeedDetail.route, 0); - NavbarNotifier.showSnackBar(context, 'switching to Home', - onClosed: () { - NavbarNotifier.index = 0; - }); - } else { - NavbarNotifier.hideBottomNavBar = false; - Navigate.pushNamed(context, ProductDetail.route, - transitionType: TransitionType.scale, - arguments: {'id': index.toString()}); - } - }, - child: ProductTile(index: index)), - ); - }), - ); + return ListView.builder( + controller: _scrollController, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () { + if (index == 0) { + NavbarNotifier.pushNamed(FeedDetail.route, 0); + NavbarNotifier.showSnackBar(context, 'switching to Home', + onClosed: () { + NavbarNotifier.index = 0; + }); + } else { + NavbarNotifier.hideBottomNavBar = false; + Navigate.pushNamed(context, ProductDetail.route, + transitionType: TransitionType.scale, + arguments: {'id': index.toString()}); + } + }, + child: ProductTile(index: index)), + ); + }); } } @@ -556,7 +639,6 @@ class ProductTile extends StatelessWidget { return Card( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), - width: MediaQuery.of(context).size.width, height: 120, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index 74678e6..0abff67 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -1,10 +1,13 @@ // ignore_for_file: public_member_api_docs, sort_constructors_first, avoid_print +import 'dart:math'; + import 'package:badges/badges.dart' as badges; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:navbar_router/navbar_router.dart'; +import 'package:navbar_router/src/navbar_swipeable_utls.dart'; part 'animated_navbar.dart'; @@ -155,6 +158,23 @@ class NavbarRouter extends StatefulWidget { /// Set to true will hide the badges when the tap on the navbar icon. final bool hideBadgeOnPageChanged; + /// Set to true will opt-in to horizontally swipeable navigation bar. + final bool swipeable; + + /// Configure the swipeable area on the left edge of the screen + /// + /// By default, it will be: center aligned, width = 50 pixels, top = 50 pixels, height = 0.8 x screen height + /// + /// Only take effect if **[swipeable]** is set to true + final Rect? swipeableLeftArea; + + /// Configure the swipeable area on the right edge of the screen + /// + /// By default, it will be: center aligned, width = 50 pixels, top = 50 pixels, height = 0.8 x screen height + /// + /// Only take effect if **[swipeable]** is set to true + final Rect? swipeableRightArea; + /// Take a look at the [readme](https://github.com/maheshmnj/navbar_router) for more information on how to use this package. /// /// Please help me improve this package. @@ -165,6 +185,9 @@ class NavbarRouter extends StatefulWidget { /// const NavbarRouter( {Key? key, + this.swipeable = false, + this.swipeableLeftArea, + this.swipeableRightArea, required this.destinations, required this.errorBuilder, this.shouldPopToBaseRoute = true, @@ -219,15 +242,21 @@ class _NavbarRouterState extends State NavbarNotifier.makeBadgeVisible(NavbarNotifier.currentIndex, true); initAnimation(); NavbarNotifier.index = widget.initialIndex; - _pageController = ScrollController(); + _pageController = PageController(initialPage: widget.initialIndex); NavbarNotifier.addIndexChangeListener( (newIndex) { if (!mounted) return; // print(newIndex); - _pageController.animateTo(getPixelsFromPage(newIndex), - duration: Durations.medium1, curve: Curves.ease); + if (widget.swipeable) { + _pageController.animateTo(getPixelsFromPage(newIndex), + duration: Durations.long1, curve: Curves.ease); + } else { + _pageController.jumpTo( + getPixelsFromPage(newIndex), + ); + } }, ); } @@ -254,7 +283,7 @@ class _NavbarRouterState extends State fadeAnimation = items.map((NavbarItem item) { return AnimationController( vsync: this, - value: item == items[widget.initialIndex] ? 1.0 : 1, + value: item == items[widget.initialIndex] ? 1.0 : 0, duration: Duration(milliseconds: widget.destinationAnimationDuration)); }).toList(); @@ -359,8 +388,9 @@ class _NavbarRouterState extends State } } + // Swipeable page int pageViewIndex = 0; - late ScrollController _pageController; + late PageController _pageController; bool dragging = false; @override @@ -374,6 +404,12 @@ class _NavbarRouterState extends State final bool isExitingApp = NavbarNotifier.onBackButtonPressed( behavior: widget.backButtonBehavior); widget.onBackButtonPressed!(isExitingApp); + + // callback onchange when going back the route stack + if (widget.backButtonBehavior == BackButtonBehavior.rememberHistory && + widget.onChanged != null) { + widget.onChanged!(NavbarNotifier.currentIndex); + } _handleFadeAnimation(); }, child: AnimatedBuilder( @@ -392,52 +428,16 @@ class _NavbarRouterState extends State controller: _pageController, itemBuilder: (context, i) { return KeepAliveWrapper( + keepAlive: true, child: NotificationListener( - onNotification: (OverscrollNotification value) { - print(value.overscroll); - if (value.overscroll < 0 && - _pageController.offset + - value.overscroll <= - 0) { - if (_pageController.offset != 0) { - _pageController.jumpTo(0); - } - return true; - } - if (_pageController.offset + - value.overscroll >= - _pageController - .position.maxScrollExtent) { - if (_pageController.offset != - _pageController - .position.maxScrollExtent) { - _pageController.jumpTo(_pageController - .position.maxScrollExtent); - } - return true; - } - if (value.overscroll.abs() < 0.1) { - _pageController.jumpTo( - _pageController.offset + - value.overscroll); - } else { - _pageController.animateTo( - getPixelsFromPage( - NavbarNotifier.currentIndex), - duration: Durations.short2, - curve: Curves.ease); - } - return true; - }, + onNotification: + widget.swipeable ? handleOverscroll : null, child: SizedBox( width: MediaQuery.of(context).size.width - getPadding(), child: _buildIndexedStackItem(i, context))), ); }), - // Stack(children: [ - - // ]), ), Positioned( left: 0, @@ -463,28 +463,25 @@ class _NavbarRouterState extends State NavbarNotifier.currentIndex, false); } } else { - // NavbarNotifier.index = x; - // if (widget.onChanged != null) { - // widget.onChanged!(x); - // } - // _handleFadeAnimation(); - if ((x - NavbarNotifier.currentIndex).abs() > 1) { - _pageController.jumpTo( - getPixelsFromPage(x), - ); - } else { - _pageController.animateTo(getPixelsFromPage(x), - duration: Durations.extralong1, - curve: Curves.ease); + NavbarNotifier.index = x; + if (widget.onChanged != null) { + widget.onChanged!(x); } + _handleFadeAnimation(); } }, menuItems: items), ), - Positioned( - top: 50, - width: 50, - height: MediaQuery.of(context).size.height * 0.8, + Positioned.fromRect( + rect: !widget.swipeable + ? Rect.zero + : widget.swipeableLeftArea ?? + Rect.fromLTWH( + getPadding(), + kDragAreaTop, + kDragAreaWidth, + MediaQuery.of(context).size.height * + kDragAreaHeightFactor), child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { @@ -497,15 +494,28 @@ class _NavbarRouterState extends State onDragEnd(details); }, child: Container( - color: Colors.blueAccent.withOpacity(0.5), + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.1), ), ), ), - Positioned( - top: 50, - right: 0, - width: 50, - height: MediaQuery.of(context).size.height * 0.8, + Positioned.fromRect( + rect: !widget.swipeable + ? Rect.zero + : widget.swipeableRightArea ?? + Rect.fromLTWH( + MediaQuery.of(context).size.width - + kDragAreaWidth, + kDragAreaTop, + kDragAreaWidth, + MediaQuery.of(context).size.height * + kDragAreaHeightFactor), + // top: 50, + // right: 0, + // width: 50, + // height: MediaQuery.of(context).size.height * 0.8, child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { @@ -518,7 +528,10 @@ class _NavbarRouterState extends State onDragEnd(details); }, child: Container( - color: Colors.blueAccent.withOpacity(0.3), + color: Theme.of(context) + .colorScheme + .primary + .withOpacity(0.1), ), ), ) @@ -527,7 +540,30 @@ class _NavbarRouterState extends State })); } - onDragStart(details) { + // Swipeable functions below + bool handleOverscroll(OverscrollNotification value) { + if (!dragging) return false; + print(value.overscroll); + if (value.overscroll < 0 && + _pageController.offset + value.overscroll <= 0) { + if (_pageController.offset != 0) { + _pageController.jumpTo(0); + } + return true; + } + if (_pageController.offset + value.overscroll >= + _pageController.position.maxScrollExtent) { + if (_pageController.offset != _pageController.position.maxScrollExtent) { + _pageController.jumpTo(_pageController.position.maxScrollExtent); + } + return true; + } + _pageController.jumpTo(_pageController.offset + value.overscroll); + + return true; + } + + void onDragStart(details) { if (dragging) return; if (details.localPosition.dx < 100 || details.localPosition.dx > MediaQuery.of(context).size.width - 100) { @@ -535,8 +571,8 @@ class _NavbarRouterState extends State } } - onDragUpdate(DragUpdateDetails details) { - print(details.delta); + void onDragUpdate(DragUpdateDetails details) { + // print(details.delta); if (dragging) { if (!mounted) return; @@ -546,11 +582,31 @@ class _NavbarRouterState extends State (page >= NavbarNotifier.length - 1 && details.delta.dx < -0.1)) { return; } - _pageController.jumpTo(_pageController.offset - details.delta.dx); + double newOffset = _pageController.offset - details.delta.dx; + print(newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)); + + // handle fade animation when swiping + for (int i = 0; i < fadeAnimation.length; i++) { + if (i != NavbarNotifier.currentIndex) { + var distanceFromCurrentPage = + getPixelsFromPage(NavbarNotifier.currentIndex) - newOffset; + + fadeAnimation[i].value = min( + 1.0, + distanceFromCurrentPage.abs() / + MediaQuery.of(context).size.width - + getPadding()); + } else { + fadeAnimation[i].value = + (newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)) + .clamp(0.5, 1); + } + } + _pageController.jumpTo(newOffset); } } - onDragEnd(details) { + void onDragEnd(details) { if (!mounted) { dragging = false; return; @@ -581,26 +637,3 @@ class _NavbarRouterState extends State final NavbarNotifier _navbarNotifier = NavbarNotifier(); List colors = [mediumPurple, Colors.orange, Colors.teal]; const Color mediumPurple = Color.fromRGBO(79, 0, 241, 1.0); - -class KeepAliveWrapper extends StatefulWidget { - final Widget child; - const KeepAliveWrapper({ - Key? key, - required this.child, - }) : super(key: key); - @override - KeepAliveWrapperState createState() => KeepAliveWrapperState(); -} - -class KeepAliveWrapperState extends State - with AutomaticKeepAliveClientMixin { - @override - Widget build(BuildContext context) { - super.build(context); - return widget.child; - } - - // Setting to true will force the tab to never be disposed. This could be dangerous. - @override - bool get wantKeepAlive => true; -} diff --git a/lib/src/navbar_swipeable_utls.dart b/lib/src/navbar_swipeable_utls.dart new file mode 100644 index 0000000..58cb2fc --- /dev/null +++ b/lib/src/navbar_swipeable_utls.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +const double kDragAreaTop = 50; +const double kDragAreaWidth = 50; +const double kOpacityWhenSwipeable = 0.5; +const double kDragAreaHeightFactor = 0.8; + +class KeepAliveWrapper extends StatefulWidget { + final Widget child; + final bool keepAlive; + const KeepAliveWrapper({ + Key? key, + required this.child, + required this.keepAlive, + }) : super(key: key); + @override + KeepAliveWrapperState createState() => KeepAliveWrapperState(); +} + +class KeepAliveWrapperState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + // Setting to true will force the tab to never be disposed. This could be dangerous. + @override + bool get wantKeepAlive => widget.keepAlive; +} diff --git a/lib/src/page_view.dart b/lib/src/page_view.dart deleted file mode 100644 index e69de29..0000000 diff --git a/test/navbar_router_test.dart b/test/navbar_router_test.dart index 494b827..308d602 100644 --- a/test/navbar_router_test.dart +++ b/test/navbar_router_test.dart @@ -623,8 +623,8 @@ void main() { expect(tapTargetText.textX(), findsOneWidget); await tester.tap(tapTargetText.textX()); await tester.pumpAndSettle(); - expect('switching to Home'.textX(), findsNWidgets(items.length)); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect('switching to Home'.textX(), findsNWidgets(1)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.pumpAndSettle(const Duration(seconds: 4)); final feedRoute = routes[0]!['/'].runtimeType.typeX(); final feedDetailRoute = @@ -1529,6 +1529,7 @@ void main() { // WARNING: Snackbar Test are written considering snackbars are shown across all the tabs // e.g if a snackbar is shown it will be displayed items.length times // This is because we have migrated to Stack inplace of IndexedStack + // bebaoboy: I'm changing this back to 1 because I use ListView for the swipeable navbar group('Snackbar should be controlled using NavbarNotifier:', skip: false, () { testWidgets('Snackbar should be shown', (WidgetTester tester) async { await tester.pumpWidget(boilerplate()); @@ -1536,7 +1537,7 @@ void main() { final BuildContext context = tester.element(find.byType(NavbarRouter)); NavbarNotifier.showSnackBar(context, "This is a Snackbar message"); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); }); testWidgets('Snackbar should be hidden', (WidgetTester tester) async { @@ -1546,7 +1547,7 @@ void main() { NavbarNotifier.showSnackBar(context, "This is a Snackbar message", duration: const Duration(seconds: 5)); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); NavbarNotifier.hideSnackBar(context); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNothing); @@ -1560,9 +1561,9 @@ void main() { NavbarNotifier.showSnackBar(context, "This is a Snackbar message", duration: const Duration(seconds: 5)); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.pumpAndSettle(const Duration(seconds: 4)); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.pumpAndSettle(const Duration(seconds: 1)); expect(find.byType(SnackBar), findsNothing); }); @@ -1577,7 +1578,7 @@ void main() { duration: const Duration(seconds: 5), ); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.byIcon(Icons.close).first); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNothing); @@ -1597,7 +1598,7 @@ void main() { }, ); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.byIcon(Icons.close).first); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNothing); @@ -1618,7 +1619,7 @@ void main() { duration: const Duration(seconds: 5), ); await tester.pumpAndSettle(); - expect(find.byType(SnackBar), findsNWidgets(items.length)); + expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.text("Tap me").first); await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNothing); From 2b936182f2ba4ce4852e0c34bdfb2fca46a1bd43 Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Thu, 18 Jul 2024 21:06:07 +0700 Subject: [PATCH 4/8] added tests --- lib/src/navbar_router.dart | 3 + test/navbar_router_test.dart | 106 ++++++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index 0abff67..71cf020 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -483,6 +483,7 @@ class _NavbarRouterState extends State MediaQuery.of(context).size.height * kDragAreaHeightFactor), child: GestureDetector( + key: const ObjectKey("swipe-left"), behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { onDragStart(details); @@ -502,6 +503,8 @@ class _NavbarRouterState extends State ), ), Positioned.fromRect( + key: const ObjectKey("swipe-right"), + rect: !widget.swipeable ? Rect.zero : widget.swipeableRightArea ?? diff --git a/test/navbar_router_test.dart b/test/navbar_router_test.dart index 308d602..0db8e21 100644 --- a/test/navbar_router_test.dart +++ b/test/navbar_router_test.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'dart:math'; +import 'dart:ui'; import 'package:badges/badges.dart' as badges; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:navbar_router/navbar_router.dart'; @@ -117,6 +119,7 @@ void main() { child: MediaQuery( data: MediaQueryData(size: size), child: NavbarRouter( + swipeable: true, errorBuilder: (context) { return const Center(child: Text('Error 404')); }, @@ -1580,7 +1583,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.byIcon(Icons.close).first); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 5)); expect(find.byType(SnackBar), findsNothing); }); @@ -1600,7 +1603,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(SnackBar), findsNWidgets(1)); await tester.tap(find.byIcon(Icons.close).first); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(const Duration(seconds: 5)); expect(find.byType(SnackBar), findsNothing); expect(isClosed, true); }); @@ -1794,4 +1797,103 @@ void main() { await tester.pumpAndSettle(); expect((navItems[2].selectedIcon as Icon).color, Colors.green); }); + + group('Swipeable extra test', () { + testWidgets('Should not be able to drag on center', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.dragFrom( + const Offset(400, 350), const Offset(900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to drag left', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 1)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ObjectKey("swipe-left")), const Offset(900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to fling left', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 1)); + await tester.pumpAndSettle(); + + await tester.fling( + find.byKey(const ObjectKey("swipe-left")), const Offset(900, 0), 20); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should not be able to fling left at index 0', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.fling(find.byKey(const ObjectKey("swipe-left")), + const Offset(99900, 0), 20); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + + await tester.drag( + find.byKey(const ObjectKey("swipe-left")), const Offset(99900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(0)); + expect('Feed 0 card'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to drag right', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ObjectKey("swipe-right")), const Offset(-900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(1)); + expect('Product 1'.textX(), findsOneWidget); + }); + + testWidgets('Should be able to fling left', (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.fling(find.byKey(const ObjectKey("swipe-right")), + const Offset(-900, 0), 20); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(1)); + expect('Product 1'.textX(), findsOneWidget); + }); + + testWidgets('Should not be able to fling left at last index', + (WidgetTester tester) async { + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpAndSettle(); + + await tester.fling(find.byKey(const ObjectKey("swipe-right")), + const Offset(-99900, 0), 20); + await tester.pumpAndSettle(); + + await tester.drag( + find.byKey(const ObjectKey("swipe-right")), const Offset(-99900, 0)); + await tester.pumpAndSettle(); + + expect(NavbarNotifier.currentIndex, equals(3)); + expect('Slide to change theme color'.textX(), findsOneWidget); + }); + }); } From 46524025c826092f5a1ead0fbb89e3ef5f358d6a Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Fri, 19 Jul 2024 23:09:45 +0700 Subject: [PATCH 5/8] make docs more clear --- example/lib/main.dart | 7 ++++ lib/src/navbar_router.dart | 62 ++++++++++++++++++++---------------- test/navbar_router_test.dart | 12 +++---- 3 files changed, 47 insertions(+), 34 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 8ad076e..adbb325 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -253,6 +253,13 @@ class _HomePageState extends ConsumerState { body: Builder(builder: (context) { return NavbarRouter( swipeable: swipeable, + // swipeableLeftArea: Rect.fromLTWH( + // 0, 50, 50, MediaQuery.of(context).size.height * 0.9), + // swipeableRightArea: Rect.fromLTWH( + // MediaQuery.of(context).size.width - 50, + // 50, + // 50, + // MediaQuery.of(context).size.height * 0.9), errorBuilder: (context) { return const Center(child: Text('Error 404')); }, diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index 71cf020..a182a3c 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -242,6 +242,8 @@ class _NavbarRouterState extends State NavbarNotifier.makeBadgeVisible(NavbarNotifier.currentIndex, true); initAnimation(); NavbarNotifier.index = widget.initialIndex; + + // necessary init for swipeable _pageController = PageController(initialPage: widget.initialIndex); NavbarNotifier.addIndexChangeListener( @@ -261,15 +263,6 @@ class _NavbarRouterState extends State ); } - double getPageFromPixels(context) { - return _pageController.offset / - (MediaQuery.of(context).size.width - getPadding()); - } - - double getPixelsFromPage(int page) { - return (MediaQuery.of(context).size.width - getPadding()) * page; - } - void updateWidget() { items.clear(); NavbarNotifier.length = widget.destinations.length; @@ -427,7 +420,8 @@ class _NavbarRouterState extends State physics: const NeverScrollableScrollPhysics(), controller: _pageController, itemBuilder: (context, i) { - return KeepAliveWrapper( + // use keep-alive to prevent list builder to rebuild + return KeepAliveWrapper( keepAlive: true, child: NotificationListener( onNotification: @@ -472,6 +466,8 @@ class _NavbarRouterState extends State }, menuItems: items), ), + + // swipe left area Positioned.fromRect( rect: !widget.swipeable ? Rect.zero @@ -495,16 +491,17 @@ class _NavbarRouterState extends State onDragEnd(details); }, child: Container( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.1), - ), + // color: Theme.of(context) + // .colorScheme + // .primary + // .withOpacity(0.1), + ), ), ), + + // swipe right area Positioned.fromRect( key: const ObjectKey("swipe-right"), - rect: !widget.swipeable ? Rect.zero : widget.swipeableRightArea ?? @@ -515,10 +512,6 @@ class _NavbarRouterState extends State kDragAreaWidth, MediaQuery.of(context).size.height * kDragAreaHeightFactor), - // top: 50, - // right: 0, - // width: 50, - // height: MediaQuery.of(context).size.height * 0.8, child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { @@ -531,11 +524,11 @@ class _NavbarRouterState extends State onDragEnd(details); }, child: Container( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.1), - ), + // color: Theme.of(context) + // .colorScheme + // .primary + // .withOpacity(0.1), + ), ), ) ], @@ -543,7 +536,18 @@ class _NavbarRouterState extends State })); } - // Swipeable functions below + /// Swipeable functions below + // convert scrollable pixels to current index + double getPageFromPixels(context) { + return _pageController.offset / + (MediaQuery.of(context).size.width - getPadding()); + } + + double getPixelsFromPage(int page) { + return (MediaQuery.of(context).size.width - getPadding()) * page; + } + + // control when user can swipe to other page bool handleOverscroll(OverscrollNotification value) { if (!dragging) return false; print(value.overscroll); @@ -568,8 +572,9 @@ class _NavbarRouterState extends State void onDragStart(details) { if (dragging) return; - if (details.localPosition.dx < 100 || - details.localPosition.dx > MediaQuery.of(context).size.width - 100) { + if (details.localPosition.dx <= kDragAreaWidth || + details.localPosition.dx >= + MediaQuery.of(context).size.width - kDragAreaWidth) { dragging = true; } } @@ -616,6 +621,7 @@ class _NavbarRouterState extends State } print(_pageController.offset); + // when user release the drag, we calculate which page they're on var page = getPageFromPixels(context); print(page); int value = page.round(); diff --git a/test/navbar_router_test.dart b/test/navbar_router_test.dart index 0db8e21..eb0a99a 100644 --- a/test/navbar_router_test.dart +++ b/test/navbar_router_test.dart @@ -1824,15 +1824,15 @@ void main() { }); testWidgets('Should be able to fling left', (WidgetTester tester) async { - await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 1)); + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 2)); await tester.pumpAndSettle(); await tester.fling( find.byKey(const ObjectKey("swipe-left")), const Offset(900, 0), 20); await tester.pumpAndSettle(); - expect(NavbarNotifier.currentIndex, equals(0)); - expect('Feed 0 card'.textX(), findsOneWidget); + expect(NavbarNotifier.currentIndex, equals(1)); + expect('Product 1'.textX(), findsOneWidget); }); testWidgets('Should not be able to fling left at index 0', @@ -1867,7 +1867,7 @@ void main() { expect('Product 1'.textX(), findsOneWidget); }); - testWidgets('Should be able to fling left', (WidgetTester tester) async { + testWidgets('Should be able to fling right', (WidgetTester tester) async { await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); await tester.pumpAndSettle(); @@ -1879,9 +1879,9 @@ void main() { expect('Product 1'.textX(), findsOneWidget); }); - testWidgets('Should not be able to fling left at last index', + testWidgets('Should not be able to fling right at last index', (WidgetTester tester) async { - await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); + await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 3)); await tester.pumpAndSettle(); await tester.fling(find.byKey(const ObjectKey("swipe-right")), From 79537984cb8c663cb6789430697def0452755e9f Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Fri, 19 Jul 2024 23:20:51 +0700 Subject: [PATCH 6/8] fix landscape mode --- lib/src/navbar_router.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index a182a3c..69bd44c 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -602,8 +602,8 @@ class _NavbarRouterState extends State fadeAnimation[i].value = min( 1.0, distanceFromCurrentPage.abs() / - MediaQuery.of(context).size.width - - getPadding()); + (MediaQuery.of(context).size.width - + getPadding())); } else { fadeAnimation[i].value = (newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)) From f55c3e1f5d4623d0209489c3a344e9ee2b604583 Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Sat, 17 Aug 2024 00:01:59 +0700 Subject: [PATCH 7/8] update as requested: - only one swipeable area - refractor some code to gestures.dart --- example/lib/main.dart | 1 - lib/src/gestures.dart | 96 +++++++++++ lib/src/navbar_router.dart | 264 ++++++++++------------------- lib/src/navbar_swipeable_utls.dart | 4 +- test/navbar_router_test.dart | 10 +- 5 files changed, 192 insertions(+), 183 deletions(-) create mode 100644 lib/src/gestures.dart diff --git a/example/lib/main.dart b/example/lib/main.dart index adbb325..50da747 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -229,7 +229,6 @@ class _HomePageState extends ConsumerState { child: Icon( swipeable ? Icons.swipe : Icons.touch_app_outlined), onPressed: () { - // Programmatically toggle the Navbar visibility swipeable = !swipeable; setState(() {}); }, diff --git a/lib/src/gestures.dart b/lib/src/gestures.dart new file mode 100644 index 0000000..dedcdd9 --- /dev/null +++ b/lib/src/gestures.dart @@ -0,0 +1,96 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first, avoid_print +import 'package:flutter/material.dart'; +import 'package:navbar_router/navbar_router.dart'; +import 'package:navbar_router/src/navbar_swipeable_utls.dart'; + +class Gesture { + bool dragging = false; + int pageViewIndex = 0; + late PageController pageController; + double Function() getPadding; + BuildContext context; + Gesture({ + required this.getPadding, + required this.context, + }); + + /// Swipeable functions below + // convert scrollable pixels to current index + double getPageFromPixels(context) { + return pageController.offset / + (MediaQuery.of(context).size.width - getPadding()); + } + + double getPixelsFromPage(int page) { + return (MediaQuery.of(context).size.width - getPadding()) * page; + } + + // control when user can swipe to other page + bool handleOverscroll(OverscrollNotification value) { + if (!dragging) return false; + print(value.overscroll); + if (value.overscroll < 0 && pageController.offset + value.overscroll <= 0) { + if (pageController.offset != 0) { + pageController.jumpTo(0); + } + return true; + } + if (pageController.offset + value.overscroll >= + pageController.position.maxScrollExtent) { + if (pageController.offset != pageController.position.maxScrollExtent) { + pageController.jumpTo(pageController.position.maxScrollExtent); + } + return true; + } + pageController.jumpTo(pageController.offset + value.overscroll); + + return true; + } + + void onDragStart(details) { + if (dragging) return; + if (details.localPosition.dx <= kDragAreaWidth || + details.localPosition.dx >= + MediaQuery.of(context).size.width - kDragAreaWidth) { + dragging = true; + } + } + + double? onDragUpdate(DragUpdateDetails details) { + // print(details.delta); + double? newOffset; + if (dragging) { + var page = getPageFromPixels(context); + // print(page); + if ((page == 0 && details.delta.dx > 0.1) || + (page >= NavbarNotifier.length - 1 && details.delta.dx < -0.1)) { + return null; + } + double newOffset = pageController.offset - details.delta.dx; + print(newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)); + + // handle fade animation when swiping + + pageController.jumpTo(newOffset); + } + return newOffset; + } + + int onDragEnd(details) { + print(pageController.offset); + // when user release the drag, we calculate which page they're on + var page = getPageFromPixels(context); + print(page); + int value = page.round(); + if (value < 0) { + pageController.animateTo(pageController.position.minScrollExtent, + duration: Durations.long1, curve: Curves.ease); + } else if (value >= NavbarNotifier.length) { + pageController.animateTo(pageController.position.maxScrollExtent, + duration: Durations.long1, curve: Curves.ease); + } else {} + + dragging = false; + return value; + } +} diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index 69bd44c..c05081f 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:navbar_router/navbar_router.dart'; +import 'package:navbar_router/src/gestures.dart'; import 'package:navbar_router/src/navbar_swipeable_utls.dart'; part 'animated_navbar.dart'; @@ -161,19 +162,12 @@ class NavbarRouter extends StatefulWidget { /// Set to true will opt-in to horizontally swipeable navigation bar. final bool swipeable; - /// Configure the swipeable area on the left edge of the screen + /// Configure the swipeable area on the of the screen /// - /// By default, it will be: center aligned, width = 50 pixels, top = 50 pixels, height = 0.8 x screen height + /// By default, it will be: left center aligned, width = 50 pixels, top = 50 pixels, height = 0.8 x screen height /// /// Only take effect if **[swipeable]** is set to true - final Rect? swipeableLeftArea; - - /// Configure the swipeable area on the right edge of the screen - /// - /// By default, it will be: center aligned, width = 50 pixels, top = 50 pixels, height = 0.8 x screen height - /// - /// Only take effect if **[swipeable]** is set to true - final Rect? swipeableRightArea; + final Rect? swipeableArea; /// Take a look at the [readme](https://github.com/maheshmnj/navbar_router) for more information on how to use this package. /// @@ -186,8 +180,7 @@ class NavbarRouter extends StatefulWidget { const NavbarRouter( {Key? key, this.swipeable = false, - this.swipeableLeftArea, - this.swipeableRightArea, + this.swipeableArea, required this.destinations, required this.errorBuilder, this.shouldPopToBaseRoute = true, @@ -215,6 +208,7 @@ class _NavbarRouterState extends State final List items = []; late List fadeAnimation; List> keys = []; + late Gesture pageGesture; @override void initState() { @@ -244,7 +238,9 @@ class _NavbarRouterState extends State NavbarNotifier.index = widget.initialIndex; // necessary init for swipeable - _pageController = PageController(initialPage: widget.initialIndex); + pageGesture = Gesture(context: context, getPadding: getPadding); + pageGesture.pageController = + PageController(initialPage: widget.initialIndex); NavbarNotifier.addIndexChangeListener( (newIndex) { @@ -252,11 +248,13 @@ class _NavbarRouterState extends State // print(newIndex); if (widget.swipeable) { - _pageController.animateTo(getPixelsFromPage(newIndex), - duration: Durations.long1, curve: Curves.ease); + pageGesture.pageController.animateTo( + pageGesture.getPixelsFromPage(newIndex), + duration: Durations.long1, + curve: Curves.ease); } else { - _pageController.jumpTo( - getPixelsFromPage(newIndex), + pageGesture.pageController.jumpTo( + pageGesture.getPixelsFromPage(newIndex), ); } }, @@ -382,9 +380,6 @@ class _NavbarRouterState extends State } // Swipeable page - int pageViewIndex = 0; - late PageController _pageController; - bool dragging = false; @override Widget build(BuildContext context) { @@ -418,14 +413,15 @@ class _NavbarRouterState extends State itemCount: NavbarNotifier.length, scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), - controller: _pageController, + controller: pageGesture.pageController, itemBuilder: (context, i) { // use keep-alive to prevent list builder to rebuild - return KeepAliveWrapper( + return KeepAliveWrapper( keepAlive: true, child: NotificationListener( - onNotification: - widget.swipeable ? handleOverscroll : null, + onNotification: widget.swipeable + ? pageGesture.handleOverscroll + : null, child: SizedBox( width: MediaQuery.of(context).size.width - getPadding(), @@ -467,180 +463,100 @@ class _NavbarRouterState extends State menuItems: items), ), - // swipe left area + // swipe area Positioned.fromRect( rect: !widget.swipeable ? Rect.zero - : widget.swipeableLeftArea ?? + : widget.swipeableArea ?? Rect.fromLTWH( getPadding(), - kDragAreaTop, + MediaQuery.of(context).size.height * 0.8 - kDragAreaTop, kDragAreaWidth, - MediaQuery.of(context).size.height * + MediaQuery.of(context).size.width * kDragAreaHeightFactor), child: GestureDetector( key: const ObjectKey("swipe-left"), behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { - onDragStart(details); + if (!mounted) return; + + pageGesture.onDragStart(details); }, onHorizontalDragUpdate: (details) { - onDragUpdate(details); + if (!mounted) return; + + var newOffset = pageGesture.onDragUpdate(details); + if (newOffset == null) return; + + for (int i = 0; i < fadeAnimation.length; i++) { + if (i != NavbarNotifier.currentIndex) { + var distanceFromCurrentPage = + pageGesture.getPixelsFromPage( + NavbarNotifier.currentIndex) - + newOffset; + + fadeAnimation[i].value = min( + 1.0, + distanceFromCurrentPage.abs() / + (MediaQuery.of(context).size.width - + getPadding())); + } else { + fadeAnimation[i].value = (newOffset / + pageGesture.getPixelsFromPage( + NavbarNotifier.currentIndex)) + .clamp(0.5, 1); + } + } }, onHorizontalDragEnd: (details) { - onDragEnd(details); + if (!mounted) { + pageGesture.dragging = false; + return; + } + + var value = pageGesture.onDragEnd(details); + if (value >= 0 && value < NavbarNotifier.length) { + NavbarNotifier.index = value; + if (widget.onChanged != null) { + widget.onChanged!(value); + } + _handleFadeAnimation(); + } }, - child: Container( - // color: Theme.of(context) - // .colorScheme - // .primary - // .withOpacity(0.1), - ), + child: Container(color: Colors.red.withOpacity(0.3),), ), ), // swipe right area - Positioned.fromRect( - key: const ObjectKey("swipe-right"), - rect: !widget.swipeable - ? Rect.zero - : widget.swipeableRightArea ?? - Rect.fromLTWH( - MediaQuery.of(context).size.width - - kDragAreaWidth, - kDragAreaTop, - kDragAreaWidth, - MediaQuery.of(context).size.height * - kDragAreaHeightFactor), - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onHorizontalDragStart: (details) { - onDragStart(details); - }, - onHorizontalDragUpdate: (details) { - onDragUpdate(details); - }, - onHorizontalDragEnd: (details) { - onDragEnd(details); - }, - child: Container( - // color: Theme.of(context) - // .colorScheme - // .primary - // .withOpacity(0.1), - ), - ), - ) + // Positioned.fromRect( + // key: const ObjectKey("swipe-right"), + // rect: !widget.swipeable + // ? Rect.zero + // : widget.swipeableRightArea ?? + // Rect.fromLTWH( + // MediaQuery.of(context).size.width - + // kDragAreaWidth, + // kDragAreaTop, + // kDragAreaWidth, + // MediaQuery.of(context).size.height * + // kDragAreaHeightFactor), + // child: GestureDetector( + // behavior: HitTestBehavior.translucent, + // onHorizontalDragStart: (details) { + // onDragStart(details); + // }, + // onHorizontalDragUpdate: (details) { + // onDragUpdate(details); + // }, + // onHorizontalDragEnd: (details) { + // onDragEnd(details); + // }, + // ), + // ) ], ); })); } - - /// Swipeable functions below - // convert scrollable pixels to current index - double getPageFromPixels(context) { - return _pageController.offset / - (MediaQuery.of(context).size.width - getPadding()); - } - - double getPixelsFromPage(int page) { - return (MediaQuery.of(context).size.width - getPadding()) * page; - } - - // control when user can swipe to other page - bool handleOverscroll(OverscrollNotification value) { - if (!dragging) return false; - print(value.overscroll); - if (value.overscroll < 0 && - _pageController.offset + value.overscroll <= 0) { - if (_pageController.offset != 0) { - _pageController.jumpTo(0); - } - return true; - } - if (_pageController.offset + value.overscroll >= - _pageController.position.maxScrollExtent) { - if (_pageController.offset != _pageController.position.maxScrollExtent) { - _pageController.jumpTo(_pageController.position.maxScrollExtent); - } - return true; - } - _pageController.jumpTo(_pageController.offset + value.overscroll); - - return true; - } - - void onDragStart(details) { - if (dragging) return; - if (details.localPosition.dx <= kDragAreaWidth || - details.localPosition.dx >= - MediaQuery.of(context).size.width - kDragAreaWidth) { - dragging = true; - } - } - - void onDragUpdate(DragUpdateDetails details) { - // print(details.delta); - if (dragging) { - if (!mounted) return; - - var page = getPageFromPixels(context); - // print(page); - if ((page == 0 && details.delta.dx > 0.1) || - (page >= NavbarNotifier.length - 1 && details.delta.dx < -0.1)) { - return; - } - double newOffset = _pageController.offset - details.delta.dx; - print(newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)); - - // handle fade animation when swiping - for (int i = 0; i < fadeAnimation.length; i++) { - if (i != NavbarNotifier.currentIndex) { - var distanceFromCurrentPage = - getPixelsFromPage(NavbarNotifier.currentIndex) - newOffset; - - fadeAnimation[i].value = min( - 1.0, - distanceFromCurrentPage.abs() / - (MediaQuery.of(context).size.width - - getPadding())); - } else { - fadeAnimation[i].value = - (newOffset / getPixelsFromPage(NavbarNotifier.currentIndex)) - .clamp(0.5, 1); - } - } - _pageController.jumpTo(newOffset); - } - } - - void onDragEnd(details) { - if (!mounted) { - dragging = false; - return; - } - print(_pageController.offset); - - // when user release the drag, we calculate which page they're on - var page = getPageFromPixels(context); - print(page); - int value = page.round(); - if (value < 0) { - _pageController.animateTo(_pageController.position.minScrollExtent, - duration: Durations.long1, curve: Curves.ease); - } else if (value >= NavbarNotifier.length) { - _pageController.animateTo(_pageController.position.maxScrollExtent, - duration: Durations.long1, curve: Curves.ease); - } else { - NavbarNotifier.index = value; - if (widget.onChanged != null) { - widget.onChanged!(value); - } - _handleFadeAnimation(); - } - - dragging = false; - } } final NavbarNotifier _navbarNotifier = NavbarNotifier(); diff --git a/lib/src/navbar_swipeable_utls.dart b/lib/src/navbar_swipeable_utls.dart index 58cb2fc..b88d6f2 100644 --- a/lib/src/navbar_swipeable_utls.dart +++ b/lib/src/navbar_swipeable_utls.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; const double kDragAreaTop = 50; -const double kDragAreaWidth = 50; +const double kDragAreaWidth = 500; const double kOpacityWhenSwipeable = 0.5; -const double kDragAreaHeightFactor = 0.8; +const double kDragAreaHeightFactor = 0.3; class KeepAliveWrapper extends StatefulWidget { final Widget child; diff --git a/test/navbar_router_test.dart b/test/navbar_router_test.dart index eb0a99a..58a55ab 100644 --- a/test/navbar_router_test.dart +++ b/test/navbar_router_test.dart @@ -1,10 +1,8 @@ import 'dart:io'; import 'dart:math'; -import 'dart:ui'; import 'package:badges/badges.dart' as badges; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:navbar_router/navbar_router.dart'; @@ -1860,7 +1858,7 @@ void main() { await tester.pumpAndSettle(); await tester.drag( - find.byKey(const ObjectKey("swipe-right")), const Offset(-900, 0)); + find.byKey(const ObjectKey("swipe-left")), const Offset(-900, 0)); await tester.pumpAndSettle(); expect(NavbarNotifier.currentIndex, equals(1)); @@ -1871,7 +1869,7 @@ void main() { await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 0)); await tester.pumpAndSettle(); - await tester.fling(find.byKey(const ObjectKey("swipe-right")), + await tester.fling(find.byKey(const ObjectKey("swipe-left")), const Offset(-900, 0), 20); await tester.pumpAndSettle(); @@ -1884,12 +1882,12 @@ void main() { await tester.pumpWidget(boilerplate(type: NavbarType.floating, index: 3)); await tester.pumpAndSettle(); - await tester.fling(find.byKey(const ObjectKey("swipe-right")), + await tester.fling(find.byKey(const ObjectKey("swipe-left")), const Offset(-99900, 0), 20); await tester.pumpAndSettle(); await tester.drag( - find.byKey(const ObjectKey("swipe-right")), const Offset(-99900, 0)); + find.byKey(const ObjectKey("swipe-left")), const Offset(-99900, 0)); await tester.pumpAndSettle(); expect(NavbarNotifier.currentIndex, equals(3)); From 34a11319f851912fa54bd5098ef13625e0166682 Mon Sep 17 00:00:00 2001 From: Minh Bao Date: Sat, 17 Aug 2024 00:12:10 +0700 Subject: [PATCH 8/8] minor fix --- lib/src/navbar_router.dart | 3 +-- lib/src/navbar_swipeable_utls.dart | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/src/navbar_router.dart b/lib/src/navbar_router.dart index c05081f..1f85bb9 100644 --- a/lib/src/navbar_router.dart +++ b/lib/src/navbar_router.dart @@ -470,7 +470,7 @@ class _NavbarRouterState extends State : widget.swipeableArea ?? Rect.fromLTWH( getPadding(), - MediaQuery.of(context).size.height * 0.8 - kDragAreaTop, + kDragAreaTop, kDragAreaWidth, MediaQuery.of(context).size.width * kDragAreaHeightFactor), @@ -523,7 +523,6 @@ class _NavbarRouterState extends State _handleFadeAnimation(); } }, - child: Container(color: Colors.red.withOpacity(0.3),), ), ), diff --git a/lib/src/navbar_swipeable_utls.dart b/lib/src/navbar_swipeable_utls.dart index b88d6f2..58cb2fc 100644 --- a/lib/src/navbar_swipeable_utls.dart +++ b/lib/src/navbar_swipeable_utls.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; const double kDragAreaTop = 50; -const double kDragAreaWidth = 500; +const double kDragAreaWidth = 50; const double kOpacityWhenSwipeable = 0.5; -const double kDragAreaHeightFactor = 0.3; +const double kDragAreaHeightFactor = 0.8; class KeepAliveWrapper extends StatefulWidget { final Widget child;