From c3669f077775523f96ca26dec7cca6876bf5d555 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Sat, 8 Apr 2023 18:50:01 +0930 Subject: [PATCH 1/3] Creating simple animated list timeline --- .../ui/molecules/split_timeline_viewer.dart | 194 ++++++++++++++++++ commet/lib/ui/molecules/timeline_viewer.dart | 191 ++++------------- .../lib/ui/organisms/side_navigation_bar.dart | 19 +- commet/lib/ui/pages/chat/chat_page.dart | 24 ++- .../lib/ui/pages/chat/desktop_chat_page.dart | 37 +++- .../lib/ui/pages/chat/mobile_chat_page.dart | 32 ++- 6 files changed, 309 insertions(+), 188 deletions(-) create mode 100644 commet/lib/ui/molecules/split_timeline_viewer.dart diff --git a/commet/lib/ui/molecules/split_timeline_viewer.dart b/commet/lib/ui/molecules/split_timeline_viewer.dart new file mode 100644 index 000000000..e40b0d6e9 --- /dev/null +++ b/commet/lib/ui/molecules/split_timeline_viewer.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:commet/client/split_timeline.dart'; +import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/molecules/timeline_event.dart'; +import 'package:flutter/material.dart'; + +class SplitTimelineViewer extends StatefulWidget { + ///Child scrollable widget. + final Timeline timeline; + + const SplitTimelineViewer({required this.timeline, Key? key}) + : super(key: key); + + @override + State createState() => SplitTimelineViewerState(); +} + +class SplitTimelineViewerState extends State { + bool attachedToBottom = true; + final ScrollController controller = ScrollController(); + final ScrollPhysics physics = const BouncingScrollPhysics(); + late StreamSubscription eventAdded; + late StreamSubscription eventChanged; + late StreamSubscription eventRemoved; + + late SplitTimeline split; + + GlobalKey newEventsListKey = GlobalKey(); + GlobalKey historyListKey = GlobalKey(); + bool toBeDisposed = false; + bool animatingToBottom = false; + int hoveredEvent = -1; + + void animateAndSnapToBottom() { + if (toBeDisposed) return; + + controller.position.hold(() {}); + + TimelineEvent? lastEvent = split.recent.isNotEmpty ? split.recent[0] : null; + + animatingToBottom = true; + + controller + .animateTo(controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutExpo) + .then((value) { + TimelineEvent? latest = split.recent.isNotEmpty ? split.recent[0] : null; + if (latest == lastEvent) { + controller.jumpTo(controller.position.maxScrollExtent); + animatingToBottom = false; + } + }); + } + + bool historyLoading = false; + void loadMore() async { + if (historyLoading) return; + if (!split.isMoreHistoryAvailable()) { + historyLoading = true; + await widget.timeline.loadMoreHistory(); + historyLoading = false; + } else { + split.loadMoreHistory(); + } + + setState(() {}); + } + + void forceToBottom() { + controller.jumpTo(controller.position.maxScrollExtent); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!(controller.position.pixels >= controller.position.maxScrollExtent)) + forceToBottom(); + }); + } + + void prepareForDisposal() { + print("Preparing for disposal"); + toBeDisposed = true; + controller.position.hold(() {}); + } + + @override + void dispose() { + eventAdded.cancel(); + super.dispose(); + } + + void handleScrolling() { + if (controller.offset < controller.position.minScrollExtent + 200) { + loadMore(); + } + } + + @override + void initState() { + super.initState(); + split = SplitTimeline(widget.timeline, chunkSize: 50); + if (widget.timeline.events.length < 50) loadMore(); + + controller.addListener(() { + handleScrolling(); + handleBottomAttached(); + }); + + eventAdded = widget.timeline.onEventAdded.stream.listen((index) { + setState(() { + if (attachedToBottom || animatingToBottom) { + WidgetsBinding.instance.addPostFrameCallback((_) { + animateAndSnapToBottom(); + }); + } + }); + }); + + eventChanged = widget.timeline.onChange.stream.listen((index) { + setState(() {}); + }); + + eventRemoved = widget.timeline.onRemove.stream.listen((index) { + setState(() {}); + }); + } + + void handleBottomAttached() { + setState(() { + attachedToBottom = controller.position.pixels >= + controller.position.maxScrollExtent - 20; + }); + } + + @override + Widget build(BuildContext context) { + return CustomScrollView( + center: newEventsListKey, + controller: controller, + physics: physics, + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + int actualIndex = split.getTimelineIndex( + split.getHistoryDisplayIndex(index), + SplitTimelinePart.historical); + + return TimelineEventView( + event: split.historical[split.getHistoryDisplayIndex(index)], + showSender: shouldShowSender(split.getTimelineIndex( + split.getHistoryDisplayIndex(index), + SplitTimelinePart.historical)), + debugInfo: + "Split Part: ${split.whichList(actualIndex)} history index: $index, actual index: $actualIndex, actual index id: ${widget.timeline.events[actualIndex].eventId}", + onDelete: () { + widget.timeline.deleteEventByIndex(index); + }, + ); + }, childCount: split.historical.length)), + SliverList( + key: newEventsListKey, + delegate: SliverChildBuilderDelegate((context, index) { + int actualIndex = split.getTimelineIndex( + split.getRecentDisplayIndex(index), SplitTimelinePart.recent); + return TimelineEventView( + showSender: shouldShowSender(split.getTimelineIndex( + split.getRecentDisplayIndex(index), + SplitTimelinePart.recent)), + event: split.recent[split.getRecentDisplayIndex(index)], + debugInfo: + "Split Part: ${split.whichList(actualIndex)} history index: $index, actual index: $actualIndex, actual index id: ${widget.timeline.events[actualIndex].eventId}", + onDelete: () { + widget.timeline.deleteEventByIndex( + split.getTimelineIndex(index, SplitTimelinePart.recent)); + }, + ); + }, childCount: split.recent.length)), + ], + ); + } + + bool shouldShowSender(int index) { + if (widget.timeline.events.length <= index + 1) { + return true; + } + + if (widget.timeline.events[index].originServerTs + .difference(widget.timeline.events[index + 1].originServerTs) + .inMinutes > + 1) return true; + + return widget.timeline.events[index].sender != + widget.timeline.events[index + 1].sender; + } +} diff --git a/commet/lib/ui/molecules/timeline_viewer.dart b/commet/lib/ui/molecules/timeline_viewer.dart index 012243342..96643492f 100644 --- a/commet/lib/ui/molecules/timeline_viewer.dart +++ b/commet/lib/ui/molecules/timeline_viewer.dart @@ -1,183 +1,72 @@ -import 'dart:async'; - -import 'package:commet/client/split_timeline.dart'; -import 'package:commet/client/timeline.dart'; +import 'package:commet/ui/molecules/message.dart'; import 'package:commet/ui/molecules/timeline_event.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../../client/client.dart'; class TimelineViewer extends StatefulWidget { - ///Child scrollable widget. final Timeline timeline; - const TimelineViewer({required this.timeline, Key? key}) : super(key: key); @override - State createState() => TimelineViewerState(); + _TimelineViewerState createState() => _TimelineViewerState(); } -class TimelineViewerState extends State { - bool attachedToBottom = true; - final ScrollController controller = ScrollController(); - final ScrollPhysics physics = const BouncingScrollPhysics(); - late StreamSubscription eventAdded; - late StreamSubscription eventChanged; - late StreamSubscription eventRemoved; - - late SplitTimeline split; - - GlobalKey newEventsListKey = GlobalKey(); - GlobalKey historyListKey = GlobalKey(); - bool toBeDisposed = false; - bool animatingToBottom = false; - int hoveredEvent = -1; - - void animateAndSnapToBottom() { - if (toBeDisposed) return; - - controller.position.hold(() {}); - - TimelineEvent? lastEvent = split.recent.isNotEmpty ? split.recent[0] : null; - - animatingToBottom = true; - - controller - .animateTo(controller.position.maxScrollExtent, - duration: const Duration(milliseconds: 500), curve: Curves.easeOutExpo) - .then((value) { - TimelineEvent? latest = split.recent.isNotEmpty ? split.recent[0] : null; - if (latest == lastEvent) { - controller.jumpTo(controller.position.maxScrollExtent); - animatingToBottom = false; - } - }); - } - - bool historyLoading = false; - void loadMore() async { - if (historyLoading) return; - if (!split.isMoreHistoryAvailable()) { - historyLoading = true; - await widget.timeline.loadMoreHistory(); - historyLoading = false; - } else { - split.loadMoreHistory(); - } - - setState(() {}); - } - - void forceToBottom() { - controller.jumpTo(controller.position.maxScrollExtent); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!(controller.position.pixels >= controller.position.maxScrollExtent)) forceToBottom(); - }); - } - - void prepareForDisposal() { - print("Preparing for disposal"); - toBeDisposed = true; - controller.position.hold(() {}); - } +class _TimelineViewerState extends State { + final GlobalKey _listKey = GlobalKey(); - @override - void dispose() { - eventAdded.cancel(); - super.dispose(); - } + final ScrollController _scrollController = ScrollController(); + int _count = 0; - void handleScrolling() { - if (controller.offset < controller.position.minScrollExtent + 200) { - loadMore(); - } - } + double scrollExtent = 0; @override void initState() { - super.initState(); - split = SplitTimeline(widget.timeline, chunkSize: 50); - if (widget.timeline.events.length < 50) loadMore(); + _count = widget.timeline.events.length; + print("Initial timeline count: $_count"); - controller.addListener(() { - handleScrolling(); - handleBottomAttached(); + widget.timeline!.onEventAdded.stream.listen((index) { + insertItem(index); }); - eventAdded = widget.timeline.onEventAdded.stream.listen((index) { - setState(() { - if (attachedToBottom || animatingToBottom) { - WidgetsBinding.instance.addPostFrameCallback((_) { - animateAndSnapToBottom(); - }); - } - }); + widget.timeline!.onChange.stream.listen((index) { + _listKey.currentState?.setState(() {}); }); - eventChanged = widget.timeline.onChange.stream.listen((index) { - setState(() {}); + widget.timeline!.onRemove.stream.listen((index) { + _listKey.currentState?.removeItem(index, (_, __) => const ListTile()); + _count--; }); - eventRemoved = widget.timeline.onRemove.stream.listen((index) { - setState(() {}); - }); + super.initState(); } - void handleBottomAttached() { - setState(() { - attachedToBottom = controller.position.pixels >= controller.position.maxScrollExtent - 20; - }); + void insertItem(int index) { + _listKey.currentState?.insertItem(index); + _count++; } @override Widget build(BuildContext context) { - return CustomScrollView( - center: newEventsListKey, - controller: controller, - physics: physics, - slivers: [ - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - int actualIndex = split.getTimelineIndex(split.getHistoryDisplayIndex(index), SplitTimelinePart.historical); - - return TimelineEventView( - event: split.historical[split.getHistoryDisplayIndex(index)], - showSender: shouldShowSender( - split.getTimelineIndex(split.getHistoryDisplayIndex(index), SplitTimelinePart.historical)), - debugInfo: - "Split Part: ${split.whichList(actualIndex)} history index: $index, actual index: $actualIndex, actual index id: ${widget.timeline.events[actualIndex].eventId}", - onDelete: () { - widget.timeline.deleteEventByIndex(index); - }, - ); - }, childCount: split.historical.length)), - SliverList( - key: newEventsListKey, - delegate: SliverChildBuilderDelegate((context, index) { - int actualIndex = split.getTimelineIndex(split.getRecentDisplayIndex(index), SplitTimelinePart.recent); - return TimelineEventView( - showSender: shouldShowSender( - split.getTimelineIndex(split.getRecentDisplayIndex(index), SplitTimelinePart.recent)), - event: split.recent[split.getRecentDisplayIndex(index)], - debugInfo: - "Split Part: ${split.whichList(actualIndex)} history index: $index, actual index: $actualIndex, actual index id: ${widget.timeline.events[actualIndex].eventId}", - onDelete: () { - widget.timeline.deleteEventByIndex(split.getTimelineIndex(index, SplitTimelinePart.recent)); - }, - ); - }, childCount: split.recent.length)), - ], - ); + return buildTimeline(_listKey, _scrollController); } - bool shouldShowSender(int index) { - if (widget.timeline.events.length <= index + 1) { - return true; - } - - if (widget.timeline.events[index].originServerTs - .difference(widget.timeline.events[index + 1].originServerTs) - .inMinutes > - 1) return true; - - return widget.timeline.events[index].sender != widget.timeline.events[index + 1].sender; + SafeArea buildTimeline(Key key, ScrollController controller) { + return SafeArea( + child: Expanded( + child: AnimatedList( + key: key, + reverse: true, + physics: const BouncingScrollPhysics(), + controller: controller, + initialItemCount: _count, + itemBuilder: (context, i, animation) { + return SizeTransition( + sizeFactor: + animation.drive(CurveTween(curve: Curves.easeOutCubic)), + child: TimelineEventView(event: widget.timeline!.events[i])); + }), + )); } } diff --git a/commet/lib/ui/organisms/side_navigation_bar.dart b/commet/lib/ui/organisms/side_navigation_bar.dart index 1ac94a763..9504de445 100644 --- a/commet/lib/ui/organisms/side_navigation_bar.dart +++ b/commet/lib/ui/organisms/side_navigation_bar.dart @@ -8,12 +8,16 @@ import 'package:tiamat/tiamat.dart'; import 'package:tiamat/tiamat.dart' as tiamat; import '../../generated/l10n.dart'; import '../molecules/space_selector.dart'; -import '../molecules/timeline_viewer.dart'; +import '../molecules/split_timeline_viewer.dart'; import '../navigation/navigation_utils.dart'; import '../pages/settings/settings_page.dart'; class SideNavigationBar extends StatefulWidget { - const SideNavigationBar({super.key, this.onSpaceSelected, this.onHomeSelected, this.onSettingsSelected}); + const SideNavigationBar( + {super.key, + this.onSpaceSelected, + this.onHomeSelected, + this.onSettingsSelected}); final void Function(int index)? onSpaceSelected; final void Function()? onHomeSelected; @@ -34,7 +38,8 @@ class SideNavigationBar extends StatefulWidget { offset: 5, tailLength: 5, tailBaseWidth: 5, - backgroundColor: Theme.of(context).extension()!.surfaceLow4, + backgroundColor: + Theme.of(context).extension()!.surfaceLow4, child: child), ); } @@ -42,8 +47,9 @@ class SideNavigationBar extends StatefulWidget { class _SideNavigationBarState extends State { late ClientManager _clientManager; - late GlobalKey timelineKey = GlobalKey(); - late Map> timelines = {}; + late GlobalKey timelineKey = + GlobalKey(); + late Map> timelines = {}; @override void initState() { @@ -85,7 +91,8 @@ class _SideNavigationBarState extends State { icon: Icons.add, onTap: () { PopupDialog.show(context, - content: AddSpace(clientManager: _clientManager), title: T.of(context).addSpace); + content: AddSpace(clientManager: _clientManager), + title: T.of(context).addSpace); }, ), context), diff --git a/commet/lib/ui/pages/chat/chat_page.dart b/commet/lib/ui/pages/chat/chat_page.dart index 0715fd0ed..10cfefbb0 100644 --- a/commet/lib/ui/pages/chat/chat_page.dart +++ b/commet/lib/ui/pages/chat/chat_page.dart @@ -8,7 +8,7 @@ import 'package:flutter/widgets.dart'; import '../../../client/client.dart'; import '../../../config/build_config.dart'; -import '../../molecules/timeline_viewer.dart'; +import '../../molecules/split_timeline_viewer.dart'; class ChatPage extends StatefulWidget { const ChatPage({required this.clientManager, super.key}); @@ -22,8 +22,9 @@ class ChatPageState extends State { late Space? selectedSpace = null; late Room? selectedRoom = null; late bool homePageSelected = false; - late GlobalKey timelineKey = GlobalKey(); - late Map> timelines = {}; + late GlobalKey timelineKey = + GlobalKey(); + late Map> timelines = {}; double height = -1; void selectHomePage() { @@ -33,8 +34,9 @@ class ChatPageState extends State { void selectSpace(Space space) { if (kDebugMode) { // Weird hacky work around mentioned in #2 - timelines[selectedRoom?.identifier]?.currentState!.prepareForDisposal(); - WidgetsBinding.instance.addPostFrameCallback((_) => _setSelectedSpace(space)); + timelines[selectedRoom?.identifier]?.currentState?.prepareForDisposal(); + WidgetsBinding.instance + .addPostFrameCallback((_) => _setSelectedSpace(space)); } else { _setSelectedSpace(space); } @@ -44,7 +46,8 @@ class ChatPageState extends State { if (kDebugMode) { // Weird hacky work around mentioned in #2 timelines[selectedRoom?.identifier]?.currentState!.prepareForDisposal(); - WidgetsBinding.instance.addPostFrameCallback((_) => _clearRoomSelection()); + WidgetsBinding.instance + .addPostFrameCallback((_) => _clearRoomSelection()); } else { _clearRoomSelection(); } @@ -60,13 +63,14 @@ class ChatPageState extends State { if (room == selectedRoom) return; if (!timelines.containsKey(room.identifier)) { - timelines[room.identifier] = GlobalKey(); + timelines[room.identifier] = GlobalKey(); } if (kDebugMode) { // Weird hacky work around mentioned in #2 - timelines[selectedRoom?.identifier]?.currentState!.prepareForDisposal(); - WidgetsBinding.instance.addPostFrameCallback((_) => _setSelectedRoom(room)); + timelines[selectedRoom?.identifier]?.currentState?.prepareForDisposal(); + WidgetsBinding.instance + .addPostFrameCallback((_) => _setSelectedRoom(room)); } else { _setSelectedRoom(room); } @@ -77,7 +81,7 @@ class ChatPageState extends State { selectedRoom = room; WidgetsBinding.instance.addPostFrameCallback( (timeStamp) { - timelines[selectedRoom?.identifier]?.currentState!.forceToBottom(); + timelines[selectedRoom?.identifier]?.currentState?.forceToBottom(); }, ); }); diff --git a/commet/lib/ui/pages/chat/desktop_chat_page.dart b/commet/lib/ui/pages/chat/desktop_chat_page.dart index 2feb56f26..422c6215a 100644 --- a/commet/lib/ui/pages/chat/desktop_chat_page.dart +++ b/commet/lib/ui/pages/chat/desktop_chat_page.dart @@ -4,9 +4,10 @@ import 'package:commet/ui/atoms/drag_drop_file_target.dart'; import 'package:commet/ui/atoms/room_header.dart'; import 'package:commet/ui/atoms/space_header.dart'; import 'package:commet/ui/molecules/direct_message_list.dart'; -import 'package:commet/ui/molecules/timeline_viewer.dart'; +import 'package:commet/ui/molecules/split_timeline_viewer.dart'; import 'package:commet/ui/molecules/message_input.dart'; import 'package:commet/ui/molecules/space_viewer.dart'; +import 'package:commet/ui/molecules/timeline_viewer.dart'; import 'package:commet/ui/molecules/user_list.dart'; import 'package:commet/ui/molecules/user_panel.dart'; import 'package:commet/ui/organisms/side_navigation_bar.dart'; @@ -36,7 +37,8 @@ class _DesktopChatPageViewState extends State { Tile.low4( child: SideNavigationBar( onSpaceSelected: (index) { - widget.state.selectSpace(widget.state.clientManager.spaces[index]); + widget.state + .selectSpace(widget.state.clientManager.spaces[index]); }, onHomeSelected: () { widget.state.selectHome(); @@ -44,10 +46,16 @@ class _DesktopChatPageViewState extends State { ), ), if (widget.state.homePageSelected) homePageView(), - if (!widget.state.homePageSelected && widget.state.selectedSpace != null) spaceRoomSelector(), + if (!widget.state.homePageSelected && + widget.state.selectedSpace != null) + spaceRoomSelector(), if (widget.state.selectedRoom != null) roomChatView(), - if (widget.state.selectedSpace != null && widget.state.selectedRoom == null) - Expanded(child: SpaceSummary(key: widget.state.selectedSpace!.key, space: widget.state.selectedSpace!)), + if (widget.state.selectedSpace != null && + widget.state.selectedRoom == null) + Expanded( + child: SpaceSummary( + key: widget.state.selectedSpace!.key, + space: widget.state.selectedSpace!)), ], ), if (widget.state.selectedRoom != null) @@ -70,7 +78,8 @@ class _DesktopChatPageViewState extends State { directMessages: widget.state.clientManager.directMessages, onSelected: (index) { setState(() { - widget.state.selectRoom(widget.state.clientManager.directMessages[index]); + widget.state + .selectRoom(widget.state.clientManager.directMessages[index]); }); }, ), @@ -84,7 +93,8 @@ class _DesktopChatPageViewState extends State { borderLeft: true, child: Column( children: [ - SizedBox(height: s(50), child: RoomHeader(widget.state.selectedRoom!)), + SizedBox( + height: s(50), child: RoomHeader(widget.state.selectedRoom!)), Flexible( child: Row( children: [ @@ -94,7 +104,8 @@ class _DesktopChatPageViewState extends State { children: [ Expanded( child: TimelineViewer( - key: widget.state.timelines[widget.state.selectedRoom!.identifier], + key: widget.state + .timelines[widget.state.selectedRoom!.identifier], timeline: widget.state.selectedRoom!.timeline!, )), Tile( @@ -136,7 +147,9 @@ class _DesktopChatPageViewState extends State { child: SpaceHeader( widget.state.selectedSpace!, onTap: widget.state.clearRoomSelection, - backgroundColor: Theme.of(context).extension()!.surfaceLow1, + backgroundColor: Theme.of(context) + .extension()! + .surfaceLow1, ), )), Expanded( @@ -145,7 +158,8 @@ class _DesktopChatPageViewState extends State { key: widget.state.selectedSpace!.key, onRoomInsert: widget.state.selectedSpace!.onRoomAdded.stream, onRoomSelected: (index) { - widget.state.selectRoom(widget.state.selectedSpace!.rooms[index]); + widget.state + .selectRoom(widget.state.selectedSpace!.rooms[index]); }, )), Tile.low2( @@ -154,7 +168,8 @@ class _DesktopChatPageViewState extends State { child: Padding( padding: const EdgeInsets.all(3.0), child: UserPanel( - displayName: widget.state.selectedSpace!.client.user!.displayName, + displayName: + widget.state.selectedSpace!.client.user!.displayName, avatar: widget.state.selectedSpace!.client.user!.avatar, detail: widget.state.selectedSpace!.client.user!.detail, color: widget.state.selectedSpace!.client.user!.color, diff --git a/commet/lib/ui/pages/chat/mobile_chat_page.dart b/commet/lib/ui/pages/chat/mobile_chat_page.dart index 0d11d30de..365eab98c 100644 --- a/commet/lib/ui/pages/chat/mobile_chat_page.dart +++ b/commet/lib/ui/pages/chat/mobile_chat_page.dart @@ -1,5 +1,5 @@ import 'package:commet/ui/molecules/direct_message_list.dart'; -import 'package:commet/ui/molecules/timeline_viewer.dart'; +import 'package:commet/ui/molecules/split_timeline_viewer.dart'; import 'package:commet/ui/organisms/space_summary.dart'; import 'package:commet/ui/pages/chat/chat_page.dart'; import 'package:flutter/foundation.dart'; @@ -75,18 +75,22 @@ class _MobileChatPageViewState extends State { widget.state.selectHome(); }, onSpaceSelected: (index) { - widget.state.selectSpace(widget.state.clientManager.spaces[index]); + widget.state + .selectSpace(widget.state.clientManager.spaces[index]); }, ), ), if (widget.state.homePageSelected) homePageView(), - if (widget.state.homePageSelected == false && widget.state.selectedSpace != null) spaceRoomSelector(newContext), + if (widget.state.homePageSelected == false && + widget.state.selectedSpace != null) + spaceRoomSelector(newContext), ], ); } Widget mainPanel() { - if (widget.state.selectedSpace != null && widget.state.selectedRoom == null) { + if (widget.state.selectedSpace != null && + widget.state.selectedRoom == null) { return SpaceSummary(space: widget.state.selectedSpace!); } @@ -150,7 +154,9 @@ class _MobileChatPageViewState extends State { height: 100.1, child: SpaceHeader( widget.state.selectedSpace!, - backgroundColor: material.Theme.of(context).extension()!.surfaceLow1, + backgroundColor: material.Theme.of(context) + .extension()! + .surfaceLow1, onTap: clearSelectedRoom, ), ), @@ -170,7 +176,8 @@ class _MobileChatPageViewState extends State { child: SizedBox( height: s(70), child: UserPanel( - displayName: widget.state.selectedSpace!.client.user!.displayName, + displayName: + widget.state.selectedSpace!.client.user!.displayName, avatar: widget.state.selectedSpace!.client.user!.avatar, detail: widget.state.selectedSpace!.client.user!.detail, color: widget.state.selectedSpace!.client.user!.color, @@ -190,7 +197,8 @@ class _MobileChatPageViewState extends State { body: SafeArea( child: Column( children: [ - SizedBox(height: s(50), child: RoomHeader(widget.state.selectedRoom!)), + SizedBox( + height: s(50), child: RoomHeader(widget.state.selectedRoom!)), Flexible( child: NotificationListener( onNotification: (notification) { @@ -202,7 +210,10 @@ class _MobileChatPageViewState extends State { if (diff <= 0) return true; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - var state = widget.state.timelines[widget.state.selectedRoom?.identifier]?.currentState; + var state = widget + .state + .timelines[widget.state.selectedRoom?.identifier] + ?.currentState; if (state != null) { state.controller.jumpTo(state.controller.offset + diff); } @@ -215,8 +226,9 @@ class _MobileChatPageViewState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: TimelineViewer( - key: widget.state.timelines[widget.state.selectedRoom!.identifier], + child: SplitTimelineViewer( + key: widget.state + .timelines[widget.state.selectedRoom!.identifier], timeline: widget.state.selectedRoom!.timeline!, )), Padding( From ec5e8495b6f788e1e44099b0ba1814049bac52e5 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:09:17 +0930 Subject: [PATCH 2/3] Fixed weird scroll behaviour when first viewing a room --- .../ui/molecules/split_timeline_viewer.dart | 33 +++++++++++++++---- commet/lib/ui/molecules/timeline_viewer.dart | 3 +- commet/lib/ui/pages/chat/chat_page.dart | 3 +- .../lib/ui/pages/chat/desktop_chat_page.dart | 2 +- .../lib/ui/pages/chat/mobile_chat_page.dart | 1 + 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/commet/lib/ui/molecules/split_timeline_viewer.dart b/commet/lib/ui/molecules/split_timeline_viewer.dart index e40b0d6e9..ef7bc0335 100644 --- a/commet/lib/ui/molecules/split_timeline_viewer.dart +++ b/commet/lib/ui/molecules/split_timeline_viewer.dart @@ -18,7 +18,9 @@ class SplitTimelineViewer extends StatefulWidget { class SplitTimelineViewerState extends State { bool attachedToBottom = true; - final ScrollController controller = ScrollController(); + ScrollController controller = ScrollController(initialScrollOffset: 999999); + + bool firstFrame = true; final ScrollPhysics physics = const BouncingScrollPhysics(); late StreamSubscription eventAdded; late StreamSubscription eventChanged; @@ -34,7 +36,6 @@ class SplitTimelineViewerState extends State { void animateAndSnapToBottom() { if (toBeDisposed) return; - controller.position.hold(() {}); TimelineEvent? lastEvent = split.recent.isNotEmpty ? split.recent[0] : null; @@ -89,6 +90,7 @@ class SplitTimelineViewerState extends State { } void handleScrolling() { + if (controller == null) return; if (controller.offset < controller.position.minScrollExtent + 200) { loadMore(); } @@ -100,11 +102,6 @@ class SplitTimelineViewerState extends State { split = SplitTimeline(widget.timeline, chunkSize: 50); if (widget.timeline.events.length < 50) loadMore(); - controller.addListener(() { - handleScrolling(); - handleBottomAttached(); - }); - eventAdded = widget.timeline.onEventAdded.stream.listen((index) { setState(() { if (attachedToBottom || animatingToBottom) { @@ -122,6 +119,21 @@ class SplitTimelineViewerState extends State { eventRemoved = widget.timeline.onRemove.stream.listen((index) { setState(() {}); }); + + WidgetsBinding.instance.addPostFrameCallback( + (timeStamp) { + double extent = controller.position.maxScrollExtent; + print("Initial scroll extent: $extent"); + controller = ScrollController(initialScrollOffset: extent); + controller.addListener(() { + handleScrolling(); + handleBottomAttached(); + }); + setState(() { + firstFrame = false; + }); + }, + ); } void handleBottomAttached() { @@ -133,6 +145,13 @@ class SplitTimelineViewerState extends State { @override Widget build(BuildContext context) { + if (firstFrame) { + return Offstage(child: buildListView()); + } + return buildListView(); + } + + CustomScrollView buildListView() { return CustomScrollView( center: newEventsListKey, controller: controller, diff --git a/commet/lib/ui/molecules/timeline_viewer.dart b/commet/lib/ui/molecules/timeline_viewer.dart index 96643492f..63b879365 100644 --- a/commet/lib/ui/molecules/timeline_viewer.dart +++ b/commet/lib/ui/molecules/timeline_viewer.dart @@ -16,7 +16,8 @@ class TimelineViewer extends StatefulWidget { class _TimelineViewerState extends State { final GlobalKey _listKey = GlobalKey(); - final ScrollController _scrollController = ScrollController(); + final ScrollController _scrollController = + ScrollController(initialScrollOffset: 9999); int _count = 0; double scrollExtent = 0; diff --git a/commet/lib/ui/pages/chat/chat_page.dart b/commet/lib/ui/pages/chat/chat_page.dart index 10cfefbb0..de21a80b9 100644 --- a/commet/lib/ui/pages/chat/chat_page.dart +++ b/commet/lib/ui/pages/chat/chat_page.dart @@ -81,7 +81,7 @@ class ChatPageState extends State { selectedRoom = room; WidgetsBinding.instance.addPostFrameCallback( (timeStamp) { - timelines[selectedRoom?.identifier]?.currentState?.forceToBottom(); + // timelines[selectedRoom?.identifier]?.currentState?.forceToBottom(); }, ); }); @@ -111,6 +111,7 @@ class ChatPageState extends State { onWillPop: () async { return false; }, + // Listen to size change and offset the scroll view, so that we maintain timeline position when window changes size child: NotificationListener( onNotification: (SizeChangedLayoutNotification notification) { print("Size Changed"); diff --git a/commet/lib/ui/pages/chat/desktop_chat_page.dart b/commet/lib/ui/pages/chat/desktop_chat_page.dart index 422c6215a..4634fab92 100644 --- a/commet/lib/ui/pages/chat/desktop_chat_page.dart +++ b/commet/lib/ui/pages/chat/desktop_chat_page.dart @@ -103,7 +103,7 @@ class _DesktopChatPageViewState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: TimelineViewer( + child: SplitTimelineViewer( key: widget.state .timelines[widget.state.selectedRoom!.identifier], timeline: widget.state.selectedRoom!.timeline!, diff --git a/commet/lib/ui/pages/chat/mobile_chat_page.dart b/commet/lib/ui/pages/chat/mobile_chat_page.dart index 365eab98c..c96772216 100644 --- a/commet/lib/ui/pages/chat/mobile_chat_page.dart +++ b/commet/lib/ui/pages/chat/mobile_chat_page.dart @@ -200,6 +200,7 @@ class _MobileChatPageViewState extends State { SizedBox( height: s(50), child: RoomHeader(widget.state.selectedRoom!)), Flexible( + // We listen to this so that when the onscreen keyboard changes the size of view inset, we can offset the scroll position child: NotificationListener( onNotification: (notification) { var prevHeight = height; From 6dcb891c0c99a924087a8f2ab1ee0756d4afe823 Mon Sep 17 00:00:00 2001 From: Airyzz <36567925+Airyzz@users.noreply.github.com> Date: Sat, 8 Apr 2023 21:46:14 +0930 Subject: [PATCH 3/3] Cleaning up --- commet/lib/ui/molecules/message.dart | 23 +++++++++++++------ .../ui/molecules/split_timeline_viewer.dart | 9 +++++--- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/commet/lib/ui/molecules/message.dart b/commet/lib/ui/molecules/message.dart index 067ac68c4..a1272d3cf 100644 --- a/commet/lib/ui/molecules/message.dart +++ b/commet/lib/ui/molecules/message.dart @@ -64,13 +64,16 @@ class _MessageState extends State { Widget buildContent(BuildContext context) { return Container( - color: hovered ? material.Theme.of(context).hoverColor : material.Colors.transparent, + color: hovered + ? material.Theme.of(context).hoverColor + : material.Colors.transparent, child: Padding( - padding: EdgeInsets.fromLTRB(s(15), widget.showSender ? s(20) : s(4), 8, 4), + padding: EdgeInsets.fromLTRB(15, widget.showSender ? 10 : 4, 8, 4), child: Stack( children: [ Opacity( - opacity: widget.event.status == TimelineEventStatus.sending ? 0.5 : 1, + opacity: + widget.event.status == TimelineEventStatus.sending ? 0.5 : 1, child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, @@ -107,11 +110,13 @@ class _MessageState extends State { tiamat.Text.error(T.of(context).messageDeleted) else if (widget.event.bodyFormat != null) selectableText - ? material.SelectionArea(child: widget.event.formattedContent!) + ? material.SelectionArea( + child: widget.event.formattedContent!) : widget.event.formattedContent! else if (widget.event.body != null) selectableText - ? material.SelectionArea(child: tiamat.Text.body(widget.event.body!)) + ? material.SelectionArea( + child: tiamat.Text.body(widget.event.body!)) : tiamat.Text.body(widget.event.body!), if (widget.event.attachments != null) Wrap( @@ -142,7 +147,9 @@ class _MessageState extends State { } Widget debugInfo() { - var info = List.from([widget.event.type.toString(), widget.event.status.toString()], growable: true); + var info = List.from( + [widget.event.type.toString(), widget.event.status.toString()], + growable: true); if (widget.event.source != null) info.add(widget.event.source); return Opacity( opacity: 0.5, @@ -154,7 +161,9 @@ class _MessageState extends State { alignment: WrapAlignment.start, runAlignment: WrapAlignment.start, children: info - .map((e) => Padding(padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), child: tiamat.Text.tiny(e))) + .map((e) => Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), + child: tiamat.Text.tiny(e))) .toList(), ), ], diff --git a/commet/lib/ui/molecules/split_timeline_viewer.dart b/commet/lib/ui/molecules/split_timeline_viewer.dart index ef7bc0335..3a6be42f5 100644 --- a/commet/lib/ui/molecules/split_timeline_viewer.dart +++ b/commet/lib/ui/molecules/split_timeline_viewer.dart @@ -4,9 +4,14 @@ import 'package:commet/client/split_timeline.dart'; import 'package:commet/client/timeline.dart'; import 'package:commet/ui/molecules/timeline_event.dart'; import 'package:flutter/material.dart'; +/* + This contains a weird hack to bring the scroll view down to the bottom + On the first frame we render offstage, with an initial scroll offset of 999999 + Then on the next frame we can measure the max scroll extent, create a new scroll controller which + initializes at that max scroll extent, then we actually render on stage the following frame +*/ class SplitTimelineViewer extends StatefulWidget { - ///Child scrollable widget. final Timeline timeline; const SplitTimelineViewer({required this.timeline, Key? key}) @@ -119,11 +124,9 @@ class SplitTimelineViewerState extends State { eventRemoved = widget.timeline.onRemove.stream.listen((index) { setState(() {}); }); - WidgetsBinding.instance.addPostFrameCallback( (timeStamp) { double extent = controller.position.maxScrollExtent; - print("Initial scroll extent: $extent"); controller = ScrollController(initialScrollOffset: extent); controller.addListener(() { handleScrolling();