@@ -182,12 +182,12 @@ abstract class MessageListPageState {
182
182
}
183
183
184
184
class MessageListPage extends StatefulWidget {
185
- const MessageListPage ({super .key, required this .initNarrow});
186
-
185
+ const MessageListPage ({super .key, required this .initNarrow, this .anchorMessageId });
186
+ final int ? anchorMessageId;
187
187
static Route <void > buildRoute ({int ? accountId, BuildContext ? context,
188
- required Narrow narrow}) {
188
+ required Narrow narrow, int ? anchorMessageId }) {
189
189
return MaterialAccountWidgetRoute (accountId: accountId, context: context,
190
- page: MessageListPage (initNarrow: narrow));
190
+ page: MessageListPage (initNarrow: narrow, anchorMessageId : anchorMessageId ));
191
191
}
192
192
193
193
/// The [MessageListPageState] above this context in the tree.
@@ -302,7 +302,7 @@ class _MessageListPageState extends State<MessageListPage> implements MessageLis
302
302
removeBottom: ComposeBox .hasComposeBox (narrow),
303
303
304
304
child: Expanded (
305
- child: MessageList (narrow: narrow, onNarrowChanged: _narrowChanged))),
305
+ child: MessageList (narrow: narrow, onNarrowChanged: _narrowChanged, anchorMessageId : widget.anchorMessageId ))),
306
306
if (ComposeBox .hasComposeBox (narrow))
307
307
ComposeBox (key: _composeBoxKey, narrow: narrow)
308
308
]))));
@@ -443,11 +443,11 @@ const _kShortMessageHeight = 80;
443
443
const kFetchMessagesBufferPixels = (kMessageListFetchBatchSize / 2 ) * _kShortMessageHeight;
444
444
445
445
class MessageList extends StatefulWidget {
446
- const MessageList ({super .key, required this .narrow, required this .onNarrowChanged});
446
+ const MessageList ({super .key, required this .narrow, required this .onNarrowChanged, this .anchorMessageId });
447
447
448
448
final Narrow narrow;
449
449
final void Function (Narrow newNarrow) onNarrowChanged;
450
-
450
+ final int ? anchorMessageId;
451
451
@override
452
452
State <StatefulWidget > createState () => _MessageListState ();
453
453
}
@@ -456,6 +456,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
456
456
MessageListView ? model;
457
457
final ScrollController scrollController = ScrollController ();
458
458
final ValueNotifier <bool > _scrollToBottomVisibleValue = ValueNotifier <bool >(false );
459
+ List <MessageListItem > newItems = [];
460
+ List <MessageListItem > oldItems = [];
459
461
460
462
@override
461
463
void initState () {
@@ -476,10 +478,14 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
476
478
super .dispose ();
477
479
}
478
480
479
- void _initModel (PerAccountStore store) {
480
- model = MessageListView .init (store: store, narrow: widget.narrow);
481
+ void _initModel (PerAccountStore store) async {
482
+ model = MessageListView .init (store: store, narrow: widget.narrow, anchorMessageId : widget.anchorMessageId );
481
483
model! .addListener (_modelChanged);
482
- model! .fetchInitial ();
484
+ await model! .fetchInitial ();
485
+ setState (() {
486
+ oldItems = model! .items.sublist (0 , model! .anchorIndex! + 1 );
487
+ newItems = model! .items.sublist (model! .anchorIndex! + 1 , model! .items.length);
488
+ });
483
489
}
484
490
485
491
void _modelChanged () {
@@ -488,10 +494,54 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
488
494
// [PropagateMode.changeAll] or [PropagateMode.changeLater].
489
495
widget.onNarrowChanged (model! .narrow);
490
496
}
497
+
498
+ final previousLength = oldItems.length + newItems.length;
499
+
491
500
setState (() {
501
+ oldItems = model! .items.sublist (0 , model! .anchorIndex! + 1 );
502
+ newItems = model! .items.sublist (model! .anchorIndex! + 1 , model! .items.length);
492
503
// The actual state lives in the [MessageListView] model.
493
504
// This method was called because that just changed.
494
505
});
506
+
507
+
508
+ // Auto-scroll when new messages arrive if we're already near the bottom
509
+ if (model! .items.length > previousLength && // New messages were added
510
+ scrollController.hasClients) {
511
+ // Use post-frame callback to ensure scroll metrics are up to date
512
+ WidgetsBinding .instance.addPostFrameCallback ((_) async {
513
+ // This is to prevent auto-scrolling when fetching newer messages
514
+ if (model! .fetchingNewer || model! .fetchingOlder || model! .fetchNewerCoolingDown || model! .fetchOlderCoolingDown || ! model! .haveNewest ){
515
+ return ;
516
+ }
517
+
518
+ final viewportDimension = scrollController.position.viewportDimension;
519
+ final maxScrollExtent = scrollController.position.maxScrollExtent;
520
+ final currentScroll = scrollController.position.pixels;
521
+
522
+ // If we're within 300px of the bottommost viewport, auto-scroll
523
+ if (maxScrollExtent - currentScroll - viewportDimension < 300 ) {
524
+
525
+ final distance = scrollController.position.pixels;
526
+ final durationMsAtSpeedLimit = (1000 * distance / 8000 ).ceil ();
527
+ final durationMs = max (300 , durationMsAtSpeedLimit);
528
+
529
+ await scrollController.animateTo (
530
+ scrollController.position.maxScrollExtent,
531
+ duration: Duration (milliseconds: durationMs),
532
+ curve: Curves .ease);
533
+
534
+
535
+
536
+ if (scrollController.position.pixels + 40 < scrollController.position.maxScrollExtent ) {
537
+ await scrollController.animateTo (
538
+ scrollController.position.maxScrollExtent,
539
+ duration: Duration (milliseconds: durationMs),
540
+ curve: Curves .ease);
541
+ }
542
+ }
543
+ });
544
+ }
495
545
}
496
546
497
547
void _handleScrollMetrics (ScrollMetrics scrollMetrics) {
@@ -510,6 +560,11 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
510
560
// still not yet updated to account for the newly-added messages.
511
561
model? .fetchOlder ();
512
562
}
563
+
564
+ // Check for fetching newer messages when near the bottom
565
+ if (scrollMetrics.extentAfter < kFetchMessagesBufferPixels) {
566
+ model? .fetchNewer ();
567
+ }
513
568
}
514
569
515
570
void _scrollChanged () {
@@ -562,7 +617,8 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
562
617
}
563
618
564
619
Widget _buildListView (BuildContext context) {
565
- final length = model! .items.length;
620
+ final length = oldItems.length;
621
+ final newLength = newItems.length;
566
622
const centerSliverKey = ValueKey ('center sliver' );
567
623
568
624
Widget sliver = SliverStickyHeaderList (
@@ -587,22 +643,32 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
587
643
final valueKey = key as ValueKey <int >;
588
644
final index = model! .findItemWithMessageId (valueKey.value);
589
645
if (index == - 1 ) return null ;
590
- return length - 1 - ( index - 3 ) ;
646
+ return length - 1 - index;
591
647
},
592
- childCount: length + 3 ,
648
+ childCount: length,
593
649
(context, i) {
594
- // To reinforce that the end of the feed has been reached:
595
- // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603
596
- if (i == 0 ) return const SizedBox (height: 36 );
597
-
598
- if (i == 1 ) return MarkAsReadWidget (narrow: widget.narrow);
599
-
600
- if (i == 2 ) return TypingStatusWidget (narrow: widget.narrow);
601
-
602
- final data = model! .items[length - 1 - (i - 3 )];
650
+ final data = oldItems[length - 1 - i];
603
651
return _buildItem (data, i);
604
652
}));
605
653
654
+ Widget newMessagesSliver = SliverStickyHeaderList (
655
+ headerPlacement: HeaderPlacement .scrollingStart,
656
+ delegate: SliverChildBuilderDelegate (
657
+ findChildIndexCallback: (Key key) {
658
+ final valueKey = key as ValueKey <int >;
659
+ final index = model! .findItemWithMessageId (valueKey.value);
660
+ if (index == - 1 ) return null ;
661
+ return index- 3 ;
662
+ },
663
+ childCount: newLength+ 3 ,
664
+ (context, i) {
665
+ if (i == newLength) return TypingStatusWidget (narrow: widget.narrow);
666
+ if (i == newLength+ 1 ) return MarkAsReadWidget (narrow: widget.narrow);
667
+ if (i == newLength+ 2 ) return const SizedBox (height: 36 );
668
+ final data = newItems[i];
669
+ return _buildItem (data, i- newLength);
670
+ }));
671
+
606
672
if (! ComposeBox .hasComposeBox (widget.narrow)) {
607
673
// TODO(#311) If we have a bottom nav, it will pad the bottom
608
674
// inset, and this shouldn't be necessary
@@ -623,16 +689,16 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
623
689
624
690
controller: scrollController,
625
691
semanticChildCount: length + 2 ,
626
- anchor: 1.0 ,
692
+ anchor: 0.85 ,
627
693
center: centerSliverKey,
628
694
629
695
slivers: [
630
- sliver,
631
-
632
- // This is a trivial placeholder that occupies no space. Its purpose is
633
- // to have the key that's passed to [ScrollView.center], and so to cause
634
- // the above [SliverStickyHeaderList] to run from bottom to top.
696
+ sliver, // Main message list (grows upward)
697
+ // Center point - everything before this grows up, everything after grows down
635
698
const SliverToBoxAdapter (key: centerSliverKey),
699
+ // Static widgets and new messages (will grow downward)
700
+ newMessagesSliver, // New messages list (will grow downward)
701
+
636
702
]);
637
703
}
638
704
@@ -674,14 +740,28 @@ class ScrollToBottomButton extends StatelessWidget {
674
740
final ValueNotifier <bool > visibleValue;
675
741
final ScrollController scrollController;
676
742
677
- Future <void > _navigateToBottom () {
743
+ Future <void > _navigateToBottom () async {
744
+ // Calculate initial scroll parameters
678
745
final distance = scrollController.position.pixels;
679
746
final durationMsAtSpeedLimit = (1000 * distance / 8000 ).ceil ();
680
747
final durationMs = max (300 , durationMsAtSpeedLimit);
681
- return scrollController.animateTo (
682
- 0 ,
748
+
749
+ // Do a single scroll attempt with a completion check
750
+ await scrollController.animateTo (
751
+ scrollController.position.maxScrollExtent,
683
752
duration: Duration (milliseconds: durationMs),
684
753
curve: Curves .ease);
754
+ var count = 1 ;
755
+ // Check if we actually reached bottom, if not try again
756
+ // This handles cases where content was loaded during scroll
757
+ while (scrollController.position.pixels + 40 < scrollController.position.maxScrollExtent) {
758
+ await scrollController.animateTo (
759
+ scrollController.position.maxScrollExtent,
760
+ duration: const Duration (milliseconds: 300 ),
761
+ curve: Curves .ease);
762
+ count++ ;
763
+ }
764
+ print ("count: $count " );
685
765
}
686
766
687
767
@override
@@ -728,6 +808,7 @@ class _TypingStatusWidgetState extends State<TypingStatusWidget> with PerAccount
728
808
}
729
809
730
810
void _modelChanged () {
811
+
731
812
setState (() {
732
813
// The actual state lives in [model].
733
814
// This method was called because that just changed.
@@ -1358,11 +1439,9 @@ class MessageWithPossibleSender extends StatelessWidget {
1358
1439
selfUserId: store.selfUserId),
1359
1440
};
1360
1441
Navigator .push (context,
1361
- MessageListPage .buildRoute (context: context, narrow: narrow));
1362
- final messageListState = context.findAncestorStateOfType <_MessageListState >();
1363
- if (messageListState != null ) {
1364
- messageListState.model? .setAnchorMessage (message.id);
1365
- }
1442
+ MessageListPage .buildRoute (context: context, narrow: narrow, anchorMessageId: message.id));
1443
+
1444
+
1366
1445
}
1367
1446
},
1368
1447
child: Padding (
0 commit comments