From 6d69283b1dd0c799cda7389dc62b5e0a40001d19 Mon Sep 17 00:00:00 2001 From: olorin99 Date: Sat, 13 Dec 2025 14:49:41 +1000 Subject: [PATCH 1/2] Add widget to control hiding on scroll. Add floating action button to explore and user feeds which scrolls to top. --- lib/src/screens/explore/explore_screen.dart | 29 ++ lib/src/screens/explore/user_screen.dart | 44 ++- lib/src/screens/feed/feed_screen.dart | 299 ++++++++++---------- lib/src/widgets/hide_on_scroll.dart | 58 ++++ 4 files changed, 269 insertions(+), 161 deletions(-) create mode 100644 lib/src/widgets/hide_on_scroll.dart diff --git a/lib/src/screens/explore/explore_screen.dart b/lib/src/screens/explore/explore_screen.dart index 7409c085..9c91e660 100644 --- a/lib/src/screens/explore/explore_screen.dart +++ b/lib/src/screens/explore/explore_screen.dart @@ -5,8 +5,10 @@ import 'package:interstellar/src/controller/server.dart'; import 'package:interstellar/src/screens/explore/explore_screen_item.dart'; import 'package:interstellar/src/utils/debouncer.dart'; import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/hide_on_scroll.dart'; import 'package:interstellar/src/widgets/paging.dart'; import 'package:interstellar/src/widgets/selection_menu.dart'; +import 'package:interstellar/src/widgets/wrapper.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -121,6 +123,7 @@ class _ExploreScreenState extends State } }, ); + late final ScrollController _scrollController; @override bool get wantKeepAlive => true; @@ -128,6 +131,8 @@ class _ExploreScreenState extends State @override void initState() { super.initState(); + + _scrollController = ScrollController(); _focusNode = widget.focusNode ?? FocusNode(); _selected = widget.selected; @@ -170,6 +175,7 @@ class _ExploreScreenState extends State ), body: AdvancedPagedScrollView( controller: _pagingController, + scrollController: _scrollController, leadingSlivers: [ if (!widget.subOnly && widget.mode != ExploreType.feeds && @@ -464,6 +470,29 @@ class _ExploreScreenState extends State ); }, ), + floatingActionButton: Wrapper( + shouldWrap: ac.profile.hideFeedUIOnScroll, + parentBuilder: (child) => HideOnScroll( + controller: _scrollController, + hiddenOffset: Offset(0, 1.5), + duration: ac.profile.animationSpeed == 0 + ? Duration.zero + : Duration( + milliseconds: (300 / ac.profile.animationSpeed).toInt(), + ), + child: child, + ), + child: FloatingActionButton( + onPressed: () { + _scrollController.animateTo( + _scrollController.position.minScrollExtent, + duration: Durations.long1, + curve: Curves.easeInOut, + ); + }, + child: const Icon(Symbols.keyboard_double_arrow_up_rounded), + ), + ), ); } diff --git a/lib/src/screens/explore/user_screen.dart b/lib/src/screens/explore/user_screen.dart index b3772869..b515a6f4 100644 --- a/lib/src/screens/explore/user_screen.dart +++ b/lib/src/screens/explore/user_screen.dart @@ -19,6 +19,7 @@ import 'package:interstellar/src/screens/feed/post_item.dart'; import 'package:interstellar/src/screens/feed/post_page.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/avatar.dart'; +import 'package:interstellar/src/widgets/hide_on_scroll.dart'; import 'package:interstellar/src/widgets/image.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; import 'package:interstellar/src/widgets/loading_template.dart'; @@ -31,6 +32,7 @@ import 'package:interstellar/src/widgets/subscription_button.dart'; import 'package:interstellar/src/widgets/tags/tag_widget.dart'; import 'package:interstellar/src/widgets/user_status_icons.dart'; import 'package:interstellar/src/widgets/menus/user_menu.dart'; +import 'package:interstellar/src/widgets/wrapper.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -50,7 +52,7 @@ class UserScreen extends StatefulWidget { class _UserScreenState extends State { DetailedUserModel? _data; late FeedSort _sort; - final ScrollController _scrollController = ScrollController(); + late final ScrollController _scrollController; final List> _feedKeyList = []; GlobalKey<_UserScreenBodyState> _getFeedKey(int index) { @@ -64,6 +66,8 @@ class _UserScreenState extends State { void initState() { super.initState(); + _scrollController = ScrollController(); + _data = widget.initData; _sort = context.read().profile.feedDefaultExploreSort; @@ -81,14 +85,12 @@ class _UserScreenState extends State { return value.copyWith(tags: [...value.tags, ...tags]); }) - .then( - (value) { - if (!mounted) return; - setState(() { - _data = value; - }); - } - ); + .then((value) { + if (!mounted) return; + setState(() { + _data = value; + }); + }); } } @@ -166,6 +168,7 @@ class _UserScreenState extends State { onTabSelected: (newIndex) => setState(() {}), child: NestedScrollView( controller: _scrollController, + floatHeaderSlivers: true, headerSliverBuilder: (context, innerBoxIsScrolled) => [ SliverToBoxAdapter( child: Column( @@ -481,6 +484,29 @@ class _UserScreenState extends State { ), ), ), + floatingActionButton: Wrapper( + shouldWrap: ac.profile.hideFeedUIOnScroll, + parentBuilder: (child) => HideOnScroll( + controller: _scrollController, + hiddenOffset: Offset(0, 1.5), + duration: ac.profile.animationSpeed == 0 + ? Duration.zero + : Duration( + milliseconds: (300 / ac.profile.animationSpeed).toInt(), + ), + child: child, + ), + child: FloatingActionButton( + onPressed: () { + _scrollController.animateTo( + _scrollController.position.minScrollExtent, + duration: Durations.long1, + curve: Curves.easeInOut, + ); + }, + child: const Icon(Symbols.keyboard_double_arrow_up_rounded), + ), + ), ); } } diff --git a/lib/src/screens/feed/feed_screen.dart b/lib/src/screens/feed/feed_screen.dart index b4cc167f..de89d48f 100644 --- a/lib/src/screens/feed/feed_screen.dart +++ b/lib/src/screens/feed/feed_screen.dart @@ -19,6 +19,7 @@ import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/actions.dart'; import 'package:interstellar/src/widgets/error_page.dart'; import 'package:interstellar/src/widgets/floating_menu.dart'; +import 'package:interstellar/src/widgets/hide_on_scroll.dart'; import 'package:interstellar/src/widgets/paging.dart'; import 'package:interstellar/src/widgets/scaffold.dart'; import 'package:interstellar/src/widgets/selection_menu.dart'; @@ -49,13 +50,13 @@ class FeedScreen extends StatefulWidget { class _FeedScreenState extends State with AutomaticKeepAliveClientMixin { + late final ScrollController _scrollController; final _fabKey = GlobalKey(); final List> _feedKeyList = []; late FeedSource _filter; late FeedView _view; FeedSort? _sort; late bool _hideReadPosts; - bool _isHidden = false; final ExpandableController _drawerController = ExpandableController( initialExpanded: true, @@ -216,6 +217,8 @@ class _FeedScreenState extends State void initState() { super.initState(); + _scrollController = widget.scrollController ?? ScrollController(); + _filter = whenLoggedIn( context, @@ -428,162 +431,149 @@ class _FeedScreenState extends State ), child: AdvancedScaffold( controller: _drawerController, - body: NotificationListener( - onNotification: (scroll) { - if (scroll.direction == ScrollDirection.forward) { - if (_isHidden) { - setState(() => _isHidden = false); - } - } else if (scroll.direction == ScrollDirection.reverse) { - if (!_isHidden) { - setState(() => _isHidden = true); - } - } - return true; - }, - child: SafeArea( - child: NestedScrollView( - controller: widget.scrollController, - floatHeaderSlivers: true, - headerSliverBuilder: (context, isScrolled) { - final currentFeedViewOption = - tabsAction?.name == feedActionSetView(context).name - ? FeedView.match( - values: ac.profile.feedViewOrder, - software: ac.serverSoftware.bitFlag, - )[DefaultTabController.of(context).index] - : feedViewSelect(context).getOption(_view).value; - final currentFeedSortOption = - tabsAction?.name == feedActionSetSort(context).name - ? ac.profile.feedSortOrder[DefaultTabController.of( - context, - ).index] - : feedSortSelect(context).getOption(sort).value; - return [ - SliverAppBar( - leading: - widget.feed == null && Breakpoints.isExpanded(context) - ? IconButton( - onPressed: () { - setState(() { - _drawerController.toggle(); - }); - ac.setExpandNavDrawer(_drawerController.expanded); - }, - icon: const Icon(Symbols.menu_rounded), - ) - : null, - floating: ac.profile.hideFeedUIOnScroll, - pinned: !ac.profile.hideFeedUIOnScroll, - snap: ac.profile.hideFeedUIOnScroll, - title: ListTile( - contentPadding: EdgeInsets.zero, - title: Text( - widget.feed != null - ? widget.feed!.name - : ac.selectedAccount + - (ac.isLoggedIn - ? '' - : ' (${l(context).guest})'), - softWrap: false, - overflow: TextOverflow.fade, - ), - subtitle: Row( - children: [ - Text(currentFeedViewOption.title(context)), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8), - child: Text('•'), - ), - Icon(currentFeedSortOption.icon, size: 20), - const SizedBox(width: 2), - Text(currentFeedSortOption.title(context)), - ], - ), - ), - actions: actions - .where( - (action) => action.location == ActionLocation.appBar, - ) - .map( - (action) => Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - tooltip: action.name, - icon: Icon(action.icon), - onPressed: action.callback, - ), - ), + body: SafeArea( + child: NestedScrollView( + controller: _scrollController, + floatHeaderSlivers: true, + headerSliverBuilder: (context, isScrolled) { + final currentFeedViewOption = + tabsAction?.name == feedActionSetView(context).name + ? FeedView.match( + values: ac.profile.feedViewOrder, + software: ac.serverSoftware.bitFlag, + )[DefaultTabController.of(context).index] + : feedViewSelect(context).getOption(_view).value; + final currentFeedSortOption = + tabsAction?.name == feedActionSetSort(context).name + ? ac.profile.feedSortOrder[DefaultTabController.of( + context, + ).index] + : feedSortSelect(context).getOption(sort).value; + return [ + SliverAppBar( + leading: + widget.feed == null && Breakpoints.isExpanded(context) + ? IconButton( + onPressed: () { + setState(() { + _drawerController.toggle(); + }); + ac.setExpandNavDrawer(_drawerController.expanded); + }, + icon: const Icon(Symbols.menu_rounded), ) - .toList(), - bottom: tabsAction == null || tabs == null - ? null - : TabBar( - tabAlignment: tabs.length > 5 - ? TabAlignment.start - : null, - isScrollable: tabs.length > 5, - tabs: tabs, - ), + : null, + floating: ac.profile.hideFeedUIOnScroll, + pinned: !ac.profile.hideFeedUIOnScroll, + snap: ac.profile.hideFeedUIOnScroll, + title: ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + widget.feed != null + ? widget.feed!.name + : ac.selectedAccount + + (ac.isLoggedIn ? '' : ' (${l(context).guest})'), + softWrap: false, + overflow: TextOverflow.fade, + ), + subtitle: Row( + children: [ + Text(currentFeedViewOption.title(context)), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('•'), + ), + Icon(currentFeedSortOption.icon, size: 20), + const SizedBox(width: 2), + Text(currentFeedSortOption.title(context)), + ], + ), ), - ]; - }, - body: Builder( - builder: (context) { - final controller = tabsAction == null - ? null - : DefaultTabController.of(context); - return tabsAction == null - ? FeedScreenBody( - key: _getFeedKey(0), - feed: - widget.feed ?? - FeedAggregator.fromSingleSource( - ac, - name: widget.feed?.name ?? '', - source: _filter, - ), - sort: sort, - view: _view, - details: widget.details, - hideReadPosts: _hideReadPosts, - isActive: true, - ) - : TabBarView( - physics: appTabViewPhysics(context), - children: _getFeedBodies( - ac, - tabsAction, - userCanModerate, - controller, + actions: actions + .where( + (action) => action.location == ActionLocation.appBar, + ) + .map( + (action) => Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + tooltip: action.name, + icon: Icon(action.icon), + onPressed: action.callback, ), - ); - }, - ), + ), + ) + .toList(), + bottom: tabsAction == null || tabs == null + ? null + : TabBar( + tabAlignment: tabs.length > 5 + ? TabAlignment.start + : null, + isScrollable: tabs.length > 5, + tabs: tabs, + ), + ), + ]; + }, + body: Builder( + builder: (context) { + final controller = tabsAction == null + ? null + : DefaultTabController.of(context); + return tabsAction == null + ? FeedScreenBody( + key: _getFeedKey(0), + feed: + widget.feed ?? + FeedAggregator.fromSingleSource( + ac, + name: widget.feed?.name ?? '', + source: _filter, + ), + sort: sort, + view: _view, + details: widget.details, + hideReadPosts: _hideReadPosts, + isActive: true, + ) + : TabBarView( + physics: appTabViewPhysics(context), + children: _getFeedBodies( + ac, + tabsAction, + userCanModerate, + controller, + ), + ); + }, ), ), ), - floatingActionButton: AnimatedSlide( - offset: _isHidden && ac.profile.hideFeedUIOnScroll - ? Offset(0, 0.2) - : Offset.zero, - duration: ac.profile.animationSpeed == 0 - ? Duration.zero - : Duration( - milliseconds: (300 / ac.profile.animationSpeed).toInt(), - ), - child: FloatingMenu( - key: _fabKey, - tapAction: actions - .where((action) => action.location == ActionLocation.fabTap) - .firstOrNull, - holdAction: actions - .where((action) => action.location == ActionLocation.fabHold) - .firstOrNull, - menuActions: actions - .where((action) => action.location == ActionLocation.fabMenu) - .toList(), - ), + floatingActionButton: Wrapper( + shouldWrap: ac.profile.hideFeedUIOnScroll, + parentBuilder: (child) => HideOnScroll( + controller: _scrollController, + hiddenOffset: Offset(0, 0.2), + duration: ac.profile.animationSpeed == 0 + ? Duration.zero + : Duration( + milliseconds: (300 / ac.profile.animationSpeed).toInt(), + ), + child: child + ), + child: FloatingMenu( + key: _fabKey, + tapAction: actions + .where((action) => action.location == ActionLocation.fabTap) + .firstOrNull, + holdAction: actions + .where((action) => action.location == ActionLocation.fabHold) + .firstOrNull, + menuActions: actions + .where((action) => action.location == ActionLocation.fabMenu) + .toList(), + ), ), drawer: (widget.feed != null) ? null @@ -942,7 +932,12 @@ class _FeedScreenBodyState extends State _pagingController.updateItem(item, newValue), onTap: onPostTap, isPreview: true, - onReply: whenLoggedIn(context, (body, lang, {XFile? image, String? alt}) async { + onReply: whenLoggedIn(context, ( + body, + lang, { + XFile? image, + String? alt, + }) async { await ac.api.comments.create( item.type, item.id, diff --git a/lib/src/widgets/hide_on_scroll.dart b/lib/src/widgets/hide_on_scroll.dart new file mode 100644 index 00000000..f2e3b543 --- /dev/null +++ b/lib/src/widgets/hide_on_scroll.dart @@ -0,0 +1,58 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class HideOnScroll extends StatefulWidget { + const HideOnScroll({ + super.key, + required this.controller, + required this.hiddenOffset, + this.duration = Duration.zero, + required this.child, + }); + + final ScrollController? controller; + final Offset hiddenOffset; + final Duration duration; + final Widget child; + + @override + State createState() => _HideOnScrollState(); + +} + +class _HideOnScrollState extends State { + + bool _hidden = false; + + void _onScroll() { + final scrollDirection = widget.controller?.position.userScrollDirection; + if (scrollDirection == ScrollDirection.forward && _hidden) { + setState(() => _hidden = false); + } else if (scrollDirection == ScrollDirection.reverse) { + setState(() => _hidden = true); + } + } + + @override + void initState() { + super.initState(); + widget.controller?.addListener(_onScroll); + } + + @override + Widget build(BuildContext context) { + return AnimatedSlide( + offset: _hidden ? widget.hiddenOffset : Offset.zero, + duration: widget.duration, + child: widget.child, + ); + } + + @override + void dispose() { + widget.controller?.removeListener(_onScroll); + super.dispose(); + } + +} \ No newline at end of file From 6b1a8caac2df85d70d60520bec33ab81954368e4 Mon Sep 17 00:00:00 2001 From: olorin99 Date: Sat, 13 Dec 2025 15:38:47 +1000 Subject: [PATCH 2/2] Add func to calc animation duration. Adjust user screen floating button offset. --- lib/src/app.dart | 6 +----- lib/src/controller/controller.dart | 12 ++++++++++++ lib/src/screens/explore/explore_screen.dart | 6 +----- lib/src/screens/explore/user_screen.dart | 8 ++------ lib/src/screens/feed/feed_screen.dart | 6 +----- 5 files changed, 17 insertions(+), 21 deletions(-) diff --git a/lib/src/app.dart b/lib/src/app.dart index 00d0a6ae..884c19bf 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -76,11 +76,7 @@ class App extends StatelessWidget { darkIsTrueBlack: ac.profile.enableTrueBlack, ), themeMode: ac.profile.themeMode, - themeAnimationDuration: ac.profile.animationSpeed == 0 - ? Duration.zero - : Duration( - milliseconds: (300 / ac.profile.animationSpeed).toInt(), - ), + themeAnimationDuration: ac.calcAnimationDuration(), scaffoldMessengerKey: scaffoldMessengerKey, home: AppHome(), ), diff --git a/lib/src/controller/controller.dart b/lib/src/controller/controller.dart index 5e078bb9..bb4537f6 100644 --- a/lib/src/controller/controller.dart +++ b/lib/src/controller/controller.dart @@ -1125,4 +1125,16 @@ class AppController with ChangeNotifier { final query = database.select(database.tags); return query.get(); } + + Duration calcAnimationDuration({ + Duration initialDuration = const Duration(milliseconds: 300), + }) { + return profile.animationSpeed == 0 + ? Duration.zero + : Duration( + milliseconds: + (initialDuration.inMilliseconds / profile.animationSpeed) + .toInt(), + ); + } } diff --git a/lib/src/screens/explore/explore_screen.dart b/lib/src/screens/explore/explore_screen.dart index 9c91e660..12ae6cbc 100644 --- a/lib/src/screens/explore/explore_screen.dart +++ b/lib/src/screens/explore/explore_screen.dart @@ -475,11 +475,7 @@ class _ExploreScreenState extends State parentBuilder: (child) => HideOnScroll( controller: _scrollController, hiddenOffset: Offset(0, 1.5), - duration: ac.profile.animationSpeed == 0 - ? Duration.zero - : Duration( - milliseconds: (300 / ac.profile.animationSpeed).toInt(), - ), + duration: ac.calcAnimationDuration(), child: child, ), child: FloatingActionButton( diff --git a/lib/src/screens/explore/user_screen.dart b/lib/src/screens/explore/user_screen.dart index b515a6f4..8a537680 100644 --- a/lib/src/screens/explore/user_screen.dart +++ b/lib/src/screens/explore/user_screen.dart @@ -488,12 +488,8 @@ class _UserScreenState extends State { shouldWrap: ac.profile.hideFeedUIOnScroll, parentBuilder: (child) => HideOnScroll( controller: _scrollController, - hiddenOffset: Offset(0, 1.5), - duration: ac.profile.animationSpeed == 0 - ? Duration.zero - : Duration( - milliseconds: (300 / ac.profile.animationSpeed).toInt(), - ), + hiddenOffset: Offset(0, 2), + duration: ac.calcAnimationDuration(), child: child, ), child: FloatingActionButton( diff --git a/lib/src/screens/feed/feed_screen.dart b/lib/src/screens/feed/feed_screen.dart index de89d48f..24d7b505 100644 --- a/lib/src/screens/feed/feed_screen.dart +++ b/lib/src/screens/feed/feed_screen.dart @@ -555,11 +555,7 @@ class _FeedScreenState extends State parentBuilder: (child) => HideOnScroll( controller: _scrollController, hiddenOffset: Offset(0, 0.2), - duration: ac.profile.animationSpeed == 0 - ? Duration.zero - : Duration( - milliseconds: (300 / ac.profile.animationSpeed).toInt(), - ), + duration: ac.calcAnimationDuration(), child: child ), child: FloatingMenu(