-
Notifications
You must be signed in to change notification settings - Fork 223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
msglist: add scroll-to-bottom button to MessageList #223
Conversation
013c739
to
fdc9d0c
Compare
@gnprice @chrisbobbe Looking for some draft-stage comments on this! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sirpengi! Generally this direction looks good — I think the only comment on the high-level direction is about how to listen for scroll events. Please see that and a few other comments below, and a couple of tests would also be very helpful.
It looks like we don't currently have any tests for this MessageList widget. I think at this point we have all the test infrastructure to be able to write tests for it, but there might still be something we're missing — please ask in #mobile-team if you run into any trouble.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sirpengi! I've now gone through the code for a full review; detailed comments below.
I think this structure is working well, and I'm happy to see that the tests turned out pretty clean too. Most of the comments I have are stylistic, and many of those are minor nits; there's a few that are more substantive, but I don't think any of them will be complex to deal with.
test/widgets/message_list_test.dart
Outdated
testWidgets('scrolling changes visibility', (WidgetTester tester) async { | ||
final stream = eg.stream(); | ||
await setupMessageListPage(tester, narrow: StreamNarrow(stream.streamId)); | ||
|
||
final scrollController = findMessageListScrollController(tester)!; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like there are some tab characters in here. I don't actually notice when reading in Android Studio — I guess I have tab stops of 2 columns and that must be the same as you were writing with — but it makes things harder to read on GitHub and in git log
.
test/widgets/message_list_test.dart
Outdated
} | ||
ScrollToBottomButton? findScrollToBottomButton(WidgetTester tester) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: separate these with a blank line
test/widgets/message_list_test.dart
Outdated
|
||
group('ScrollToBottomButton interactions', () { | ||
ScrollController? findMessageListScrollController(WidgetTester tester) { | ||
final stickyHeaderListView = tester.widget<StickyHeaderListView>(find.byType(StickyHeaderListView))!; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
analyzer complains this !
is redundant
test/widgets/message_list_test.dart
Outdated
/// Prepare a [MessageListPage] | ||
Future<void> setupMessageListPage(WidgetTester tester, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: This comment doesn't really add information that isn't in the name, so better to leave out.
test/widgets/message_list_test.dart
Outdated
final scrollToBottomButton = findScrollToBottomButton(tester)!; | ||
|
||
// Initial state should be not visible, as the message list renders with latest message in view | ||
check(scrollToBottomButton.visibleValue.value).equals(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than look at visibleValue
, I'd prefer to have the test check more directly that the button is visible, or isn't. That seems like it's the real contract we're asking this part of the UI logic to fulfill; so testing at that layer lets visibleValue
(and even the ScrollToBottomButton
class, I think) be an implementation detail that can be freely refactored. It also enables the test to catch bugs in a wider swath of the code, in case some future change were to break something in the connection from visibleValue
to the actual showing of the button.
I think find.byTooltip
gives a good way to do that.
lib/widgets/message_list.dart
Outdated
Positioned( | ||
bottom: 0, | ||
right: 0, | ||
child: ScrollToBottomButton(scrollController: scrollController, visibleValue: _scrollToBottomVisibleValue), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this line is awfully long — let's break the arguments onto their own lines
lib/widgets/message_list.dart
Outdated
final ScrollController scrollController = ScrollController(); | ||
|
||
final ValueNotifier<bool> _scrollToBottomVisibleValue = ValueNotifier<bool>(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we're having this state create these resources, let's have it dispose them in its own dispose
method.
I'm not sure that ultimately affects anything in this case — maybe nothing else is left that has a reference to them by the time this state gets disposed — but I don't know a reason to be sure it doesn't matter here, and the pattern "always dispose all the state's resources in dispose
" seems like a good one to consistently follow because sometimes it does matter.
lib/widgets/message_list.dart
Outdated
// Calculate time necessary if we're capping speed at 8pixels/ms (== 8000pixels/s) | ||
final speedLimit = (distance / 8).ceil(); | ||
final int durationMs = max(300, speedLimit); | ||
scrollController.animateTo(0, duration: Duration(milliseconds: durationMs), curve: Curves.easeIn); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: break these arguments onto separate lines — in particular the value for curve
is a meaningful UI choice, and it's easy for it to get lost off on the right past 80 columns
lib/widgets/message_list.dart
Outdated
// Calculate time necessary if we're capping speed at 8pixels/ms (== 8000pixels/s) | ||
final speedLimit = (distance / 8).ceil(); | ||
final int durationMs = max(300, speedLimit); | ||
scrollController.animateTo(0, duration: Duration(milliseconds: durationMs), curve: Curves.easeIn); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you tell me about your reasoning for picking Curves.easeIn
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hate to admit I didn't review all the curve behaviors fully. I did notice that the web behavior is to jump to the bottom and the mobile behavior seems linear. In my previous work with UI I've always found ease-type functions to feel more natural. I must admit my 300ms timer was also a sort of "default practice" for UI based on previous work with animation timings. To evaluate this topic more, I've looked over the material design guidance for motion, see https://m2.material.io/design/motion/speed.html#controlling-speed and https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration and while I got the 300ms perfectly right for their recommended default animation they both recommend a quick-start and slow down for easing, which I believe maps more to the Curves.ease
rather than Curves.easeIn
. I'd conclude I should switch this to Curves.ease
instead to match? Unless there is another style guide to consider in this design?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. Yeah, that fast-start, slow-finish curve sounds good.
void main() { | ||
TestZulipBinding.ensureInitialized(); | ||
|
||
group('ScrollToBottomButton interactions', () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's have one more test case — testing that the button actually works :-)
e8b9cca
to
fccf8c6
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @sirpengi for the revision! This is very close to merge now — one substantive comment on the new test case, and a few nits.
See below, and also two small things above that remain open:
#223 (comment)
#223 (comment)
When I merge this, I'll also squash all the commits into one, because ultimately they're all revisions to a single coherent change.
Many PRs end up as several commits because they make preparatory refactors, or add a feature in one place and then another feature that builds on that; in order to support those, our usual workflow is that each revision is a new draft of the whole final sequence of commits to merge, which means the author does this sort of squashing. (That does cause GitHub to be less helpful about comparing different revisions; the reviewer instead relies on having fetched a local copy, and we have a couple of scripts to help make that easy.)
But for this PR, go ahead and carry on adding commits on top. Squashing at merge time is easy when it's all going to be a single commit (as this is), and having gone most of the way in this style it's probably simplest all around to finish it that way.
|
||
await tester.tap(find.byType(ScrollToBottomButton)); | ||
await tester.pumpAndSettle(); | ||
check(isButtonVisible(tester)).equals(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's check instead that the scroll controller's actual position is at the bottom — that way this test wouldn't accidentally pass if we introduced some bug where the button didn't actually work, but did cause the button to go away 🙂
test/widgets/message_list_test.dart
Outdated
check(isButtonVisible(tester)).equals(false); | ||
}); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: missing final newline
lib/widgets/message_list.dart
Outdated
right: 0, | ||
child: ScrollToBottomButton( | ||
scrollController: scrollController, | ||
visibleValue: _scrollToBottomVisibleValue))]))))))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
visibleValue: _scrollToBottomVisibleValue))]))))))); | |
visibleValue: _scrollToBottomVisibleValue)), | |
]))))))); |
(For lists […]
we end the last item with comma-newline just like the other items, while for child
arguments and many other function arguments, as you've seen, we stack up parens on the final line.
This reflects the fact that the different elements of a list one often wants to think of as relatively equal/symmetric siblings, and one may well remove the last element or insert more after it; whereas a child
argument one thinks of differently from the more metadata-like other arguments, and we'll never insert another argument after a widget's child
.)
f556b8d
to
e4cf32b
Compare
@gnprice okay I believe this is ready again, with all comments addressed. I've rebased onto an updated |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great — this all looks good, with just one nit below which I notice on re-read. I'll fix that, squash as discussed above, and merge. Thanks again @sirpengi for all your work on this!
test/widgets/message_list_test.dart
Outdated
import '../model/binding.dart'; | ||
|
||
|
||
Future<void> setupMessageListPage(WidgetTester tester, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
import '../model/binding.dart'; | |
Future<void> setupMessageListPage(WidgetTester tester, { | |
import '../model/binding.dart'; | |
Future<void> setupMessageListPage(WidgetTester tester, { |
e4cf32b
to
5abeb88
Compare
Introducing a scroll-to-bottom button in the
MessageList
component that shows up only if the user is not at the bottom of the list. Clicking on that button triggers a scroll to the bottom.Notes