Flutter прилипание скролла (scroll snap) в SliverList

Пытаюсь сделать прилипание скролла для SliverList:

Всё прекрасно работает ровно до ресайза:

import 'dart:math' as math;

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class _ScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
        PointerDeviceKind.touch,
        PointerDeviceKind.mouse,
      };
}

const String title = 'Snapping Slivers';

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) => MaterialApp(
        debugShowCheckedModeBanner: false,
        scrollBehavior: _ScrollBehavior(),
        title: title,
        home: const _SliverParentWidget(),
      );
}

class _SliverParentWidget extends StatefulWidget {
  const _SliverParentWidget({Key? key}) : super(key: key);

  @override
  State<_SliverParentWidget> createState() => _SliverParentWidgetState();
}

class _SliverParentWidgetState extends State<_SliverParentWidget> {
  final List<int> _list = List<int>.generate(30, (index) => index);
  final int itemsCount = 3;

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(
          title: const Text(title),
        ),
        body: CustomScrollView(
          physics: _Physics(itemsCount: itemsCount),
          scrollDirection: Axis.horizontal,
          slivers: [
            SliverList(
              delegate: SliverChildBuilderDelegate(
                (context, index) => Container(
                  key: ValueKey(index),
                  width: MediaQuery.of(context).size.width / itemsCount,
                  alignment: Alignment.center,
                  color: Colors.blue[200 + _list[index] % itemsCount * 100],
                  child: Text(_list[index].toString()),
                ),
                childCount: _list.length,
              ),
            ),
          ],
        ),
      );
}

class _Physics extends ScrollPhysics {
  const _Physics({required this.itemsCount, ScrollPhysics? parent})
      : super(parent: parent);

  final int itemsCount;

  @override
  _Physics applyTo(ScrollPhysics? ancestor) =>
      _Physics(itemsCount: itemsCount, parent: buildParent(ancestor));

  @override
  double applyBoundaryConditions(ScrollMetrics position, double value) {
    if (position.pixels <= position.minScrollExtent &&
        value < position.pixels) {
      return value - position.pixels;
    }
    if (position.pixels >= position.maxScrollExtent &&
        value > position.pixels) {
      return value - position.pixels;
    }
    if (position.pixels > position.minScrollExtent &&
        value < position.minScrollExtent) {
      return value - position.minScrollExtent;
    }
    if (position.pixels < position.maxScrollExtent &&
        value > position.maxScrollExtent) {
      return value - position.maxScrollExtent;
    }
    return 0;
  }

  @override
  double get maxFlingVelocity => 1400;

  double _getTargetPixels(
      ScrollMetrics position, Tolerance tolerance, double velocity) {
    double page = position.pixels / position.viewportDimension;
    if (velocity < -tolerance.velocity) {
      page -= 0.5;
    } else if (velocity > tolerance.velocity) {
      page += 0.5;
    }
    return page * position.viewportDimension;
  }

  @override
  Simulation? createBallisticSimulation(
      ScrollMetrics position, double velocity) {
    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent) ||
        (velocity >= 0.0 && position.pixels >= position.maxScrollExtent)) {
      return super.createBallisticSimulation(position, velocity);
    }
    final Tolerance tolerance = this.tolerance;
    double target = _getTargetPixels(position, tolerance, velocity);

    // new physics
    final double distance = 200 *
        math.exp(1.2 * math.log(.6 * velocity.abs() / 800)) *
        velocity.sign;

    final double itemSize = position.viewportDimension / itemsCount;
    final int itemPosition = ((position.pixels + distance) / itemSize).round();
    target = itemPosition * itemSize;

    if (target == position.pixels) {
      return null;
    }

    return ScrollSpringSimulation(
      spring,
      position.pixels,
      target,
      velocity,
      tolerance: tolerance,
    );
  }

  @override
  bool get allowImplicitScrolling => false;
}

DartPad


Ответы (1 шт):

Автор решения: Rafiki.msc

Очень похоже, что ошибка где-то внутри самого флаттера. Во время расчета позиции экрана после проявления бага (в функции createBallisticSimulation), объект FixedScrollMetrics внутри ScrollMetrics, выдает значения отличные от того, что видно на экране. Если верить приходящим значениям, то сдвиг viewPort от начала списка совпадает с координатой элемента списка, но это не так.

Так же ошибка воспроизводится, если использовать вместо SliverList ListView.Builder с PageScrollPhysics физикой:

class ListViewWidget extends StatelessWidget {
  const ListViewWidget({
    Key? key,
    required List<int> list,
    required this.itemsCount,
  })  : _list = list,
        super(key: key);

  final int itemsCount;
  final List<int> _list;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      physics: const PageScrollPhysics(),
      scrollDirection: Axis.horizontal,
      itemCount: _list.length,
      itemBuilder: (context, index) {
        return SizedBox(
          width: MediaQuery.of(context).size.width / itemsCount,
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Text(
                index.toString(),
                style: const TextStyle(fontSize: 22.0),
              ),
            ),
          ),
        );
      },
    );
  }
}

Рекомендую завести баг в репозитории флаттера. (https://github.com/flutter/flutter/issues/new/choose)

Как временное решение, можно прокручивать список к началу при изменении размеров окна.

→ Ссылка