Irbisa · Шпаргалка

Sliver-ы и скроллинг

CustomScrollView, SliverAppBar, ScrollController, physics, NestedScrollView, keep-alive

Senior-разработчик · 22 вопроса

  1. 01

    Что такое slivers и когда экран стоит строить на CustomScrollView, а не на ListView?

    Sliver (слайвер) представляет собой часть прокручиваемой области, которая раскладывает себя от текущего смещения скролла, а не от размера бокса.

  2. 02

    Что произойдёт при раскладке этого CustomScrollView и как это исправить?

    CustomScrollView(  slivers: [    const SliverAppBar(title: Text('Orders'), pinned: true),    const Padding(      padding: EdgeInsets.all(16),      child: Text('Recent orders'),    ),    ListView.builder(      itemCount: orders.length,      itemBuilder: (_, i) => OrderTile(order: orders[i]),    ),  ],)

    В debug-режиме падает, когда viewport строит детей: «A RenderViewport expected a child of type RenderSliver but received a child of type RenderPadding».

  3. 03

    Как добавить pull-to-refresh и почему RefreshIndicator иногда не срабатывает, когда список короче экрана?

    Оберните скролл в RefreshIndicator и верните Future из onRefresh: спиннер крутится, пока он не завершится.

    RefreshIndicator(  onRefresh: () => feed.reload(),      // spinner stays until this completes  child: ListView.builder(    controller: _scroll,               // own controller: no longer primary,    physics: const AlwaysScrollableScrollPhysics(), // so restore the drag    itemCount: posts.length,…
  4. 04

    Как SliverAppBar строит схлопывающийся заголовок: за что отвечают pinned, floating, snap, expandedHeight и FlexibleSpaceBar?

    expandedHeight задаёт раскрытый размер, тулбар определяет схлопнутый, а флаги решают, что происходит между ними: pinned оставляет тулбар на экране, floating возвращает бар при любом скролле вверх, snap доводит это движение до конца при отпускании.

    // inside DefaultTabController(length: 2, child: Scaffold(body: ...))CustomScrollView(  slivers: [    SliverAppBar(      pinned: true,          // toolbar + TabBar stay on screen      floating: true,        // reappears on any upward scroll…
  5. 05

    Как SliverPersistentHeader сжимает заголовок секции со 180 px до 56 px и держит его закреплённым, и что решает shouldRebuild?

    Делегат объявляет maxExtent и minExtent; sliver резервирует maxExtent скролла и вызывает build с shrinkOffset, то есть смещением скролла, ограниченным maxExtent; полностью схлопнут заголовок при maxExtent - minExtent.

    class _SectionHeader extends SliverPersistentHeaderDelegate {  const _SectionHeader({required this.title, required this.unread});  final String title;  final int unread;   @override…
  6. 06

    Что делает shrinkWrap: true, почему он бьёт по производительности и как правильно положить список внутрь другого скролла?

    shrinkWrap: true делает список высотой с его контент вместо того, чтобы заполнять viewport, а чтобы узнать эту высоту, строит и раскладывает каждого ребёнка, так что список перестаёт быть ленивым.

    // wrong: every order is built and laid out in one frame, plus a second// Scrollable that never scrollsSingleChildScrollView(  child: Column(children: [    const SummaryCard(),    ListView.builder(…
  7. 07

    Как пользоваться ScrollController (слушать смещение, jumpTo/animateTo, запускать догрузку) и почему чтение offset в initState падает?

    ScrollController даёт доступ к подключённой ScrollPosition: читайте offset или position.extentAfter в слушателе и двигайте список через jumpTo или animateTo.

    class _FeedState extends State<Feed> {  final _scroll = ScrollController();   @override  void initState() {    super.initState();…
  8. 08

    Что не так с этим кодом бесконечного скролла и почему он запрашивает одну и ту же страницу несколько раз?

    class _FeedState extends State<Feed> {  @override  Widget build(BuildContext context) {    final controller = ScrollController();    controller.addListener(() {      if (controller.position.extentAfter < 500) {        widget.onLoadMore();      }    });    return ListView.builder(      controller: controller,      itemCount: widget.items.length,      itemBuilder: (_, i) => ItemTile(item: widget.items[i]),    );  }}

    Слушатель вызывает onLoadMore на каждом кадре скролла, пока пользователь в пределах 500 px от конца, поэтому один жест отправляет много запросов за одной и той же страницей.

  9. 09

    Найдите баг: после отправки сообщения самое новое сообщение остаётся чуть ниже видимой области.

    void _send(Message m) {  setState(() => _messages.add(m));  _scroll.animateTo(    _scroll.position.maxScrollExtent,    duration: const Duration(milliseconds: 250),    curve: Curves.easeOut,  );} @overrideWidget build(BuildContext context) => ListView.builder(      controller: _scroll,      itemCount: _messages.length,      itemBuilder: (_, i) => Bubble(message: _messages[i]),    );

    setState только помечает список «грязным»; новая строка строится и раскладывается в следующем кадре.

  10. 10

    Когда использовать NotificationListener<ScrollNotification> вместо ScrollController и почему вложенная карусель запускает догрузку ленты?

    Контроллер сообщает только о позиции, к которой подключён; NotificationListener слышит каждый скролл под собой, включая те, что создавали не вы.

    bool _onScroll(ScrollNotification n) {  // wrong without this line: the carousel below also triggers load-more  if (n.depth != 0) return false;  if (n is ScrollUpdateNotification && n.metrics.extentAfter < 600) {    loader.loadNextPage();  }…
  11. 11

    Найдите баг: на планшетах лента показывает первую страницу из 10 постов и не грузит следующую; на телефонах пагинация работает.

    @overridevoid initState() {  super.initState();  _scroll.addListener(_onScroll); // the parent has already loaded page 1} void _onScroll() {  if (_scroll.position.extentAfter < 400) widget.onLoadMore(); // guarded} @overrideWidget build(BuildContext context) => ListView.builder(      controller: _scroll,      itemCount: widget.posts.length,      itemBuilder: (_, i) => PostTile(post: widget.posts[i]),    );

    Слушатель ScrollController срабатывает только при изменении смещения.

  12. 12

    За что отвечают ScrollPhysics (Bouncing, Clamping, NeverScrollable, AlwaysScrollable) и как поменять ощущение скролла во всём приложении?

    ScrollPhysics решает, как позиция скролла реагирует на перетаскивание, на края и на fling.

    // Layered on top of the platform default, not instead of it.ListView.builder(  physics: const BouncingScrollPhysics(    parent: AlwaysScrollableScrollPhysics(), // still draggable when short  ),  itemCount: items.length,…
  13. 13

    Сколько строк за пределами видимой области на самом деле строит ListView.builder и что это значит для строки, которая делает запрос в initState?

    Всё в пределах 250 логических пикселей выше и ниже viewport строится и раскладывается, но не рисуется: это дефолтный cacheExtent.

  14. 14

    Как прокрутить ленивый список к элементу 500, если эта строка ещё ни разу не строилась?

    Нужно смещение строки в скролле, а ленивый список знает его только для уже разложенных строк.

    const rowHeight = 72.0; ListView.builder(  controller: _scroll,  itemExtent: rowHeight,               // offset of row i is i * rowHeight  itemCount: items.length,…
  15. 15

    В этом ReorderableListView строка, перетянутая вниз, встаёт на слот ниже точки броска, а стейт строк оказывается у чужих задач. Какие тут два бага?

    ReorderableListView.builder(  itemCount: tasks.length,  onReorder: (oldIndex, newIndex) {    setState(() {      final task = tasks.removeAt(oldIndex);      tasks.insert(newIndex, task);    });  },  itemBuilder: (context, i) => TaskTile(    key: ValueKey(i),    task: tasks[i],  ),)

    newIndex считается, пока перетаскиваемая строка ещё в списке, поэтому при движении вниз перед вставкой нужно вычесть единицу.

  16. 16

    Как AutomaticKeepAliveClientMixin сохраняет строку списка живой после того, как она уехала за экран, как он тихо ломается и чего это стоит?

    Ленивый sliver размонтирует строки, вышедшие за viewport плюс cache extent, и уничтожает их State.

    class _ClipRowState extends State<ClipRow>    with AutomaticKeepAliveClientMixin<ClipRow> {  late final VideoPlayerController _video =      VideoPlayerController.networkUrl(Uri.parse(widget.clip.url))        ..initialize();…
  17. 17

    Когда нужен NestedScrollView и почему первые строки каждой вкладки прячутся за его закреплённым SliverAppBar?

    Он нужен, когда схлопывающийся заголовок стоит над TabBarView или PageView, страницы которых скроллятся сами.

    // inside DefaultTabController(length: 2, child: Scaffold(body: ...))NestedScrollView(  headerSliverBuilder: (context, innerBoxIsScrolled) => [    SliverOverlapAbsorber(      handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),      sliver: SliverAppBar(…
  18. 18

    Переключение вкладок сбрасывает список наверх, и после того как Android убил приложение в фоне, оно тоже открывается наверху. Что чинит каждый случай?

    Два механизма.

    // 1. Tabs: an in-memory offset per tab, for the life of the routeTabBarView(children: [  ListView.builder(    key: const PageStorageKey<String>('tab.inbox'), // unique per tab    itemCount: inbox.length,    itemBuilder: (_, i) => MailTile(mail: inbox[i]),…
  19. 19

    Десять тысяч строк разной высоты: ползунок скроллбара всё время меняет размер, а jumpTo(maxScrollExtent) не доезжает до конца. Почему?

    SliverList не может знать высоту строки, которую не строил, поэтому maxScrollExtent остаётся оценкой, экстраполированной по уже разложенным строкам, и она меняется всякий раз, когда меняется эта выборка.

    // height is a pure function of the data: no widget needed to know itdouble extentOf(Msg m) => 16 + m.lines * 22.0; // wrong: heights are discovered by building, maxScrollExtent is an estimateListView.builder(  itemCount: msgs.length,…
  20. 20

    Чат подгружает старые сообщения вверх, а новые приходят вниз, и каждая страница истории сдвигает ленту на целый экран. Как удержать якорь на месте?

    Положите обе половины в один CustomScrollView с ключом center: slivers, перечисленные до центрального, раскладываются на отрицательных смещениях, поэтому добавление истории сдвигает minScrollExtent, а не контент под пальцем пользователя.

    class _ChatState extends State<Chat> {  static const _centerKey = ValueKey<String>('live');  final List<Message> _older = []; // index 0 = nearest the anchor  final List<Message> _live = [];  // index 0 = the anchor, grows downward   Future<void> _loadHistory() async {…
  21. 21

    В таблице первая колонка должна быть закреплена, остальное скроллится, и половинки не должны разъехаться ни на пиксель. Как их связать?

    Не одним общим ScrollController: он может держать несколько позиций, но перетаскивание двигает только свою, а чтение offset падает на assert, как только подключены две.

    class _PinnedTableState extends State<PinnedTable> {  final _left = ScrollController();  final _right = ScrollController();  bool _syncing = false;   @override…
  22. 22

    Кастомный RenderSliver рисуется где надо, но следующий sliver начинается слишком низко. Что обязан выставить performLayout и когда хватает SliverLayoutBuilder?

    performLayout обязан присвоить geometry; viewport начинает следующий sliver после вашего layoutExtent, а не после того, что вы нарисовали.

    // Reserves 200 px of scroll but pins its last 56 px:// the three extents deliberately disagree.class RenderPinnedBanner extends RenderSliver {  static const double _full = 200;  static const double _pinned = 56;…