Flutter достигает эффекта прокрутки страницы, аналогичного магазину еды на вынос Meituan.

Flutter

Во-первых, давайте посмотрим на цели и последствия

美团外卖店铺.gif 实现效果.gif

Я поставил место для деятельности здесь.TabBarнад. А почему, ха-ха, боюсь неприятностей, потому что активные компоненты Meituan Takeaway сочетаются с компонентами продуктов, указанных ниже.点菜,评价,商家Он исчезает при переключении страницы, но эта штука исчезает при скольжении страницы продукта вверх.Включая основной компонент скольжения, мы должны сделать так, чтобы скольжение из компонента списка продуктов проходило через два уровня, что действительно хлопотно. Поэтому я положил активный компонент вTabBarнад.

Затем давайте проанализируем структуру страницы

美团外卖店铺.jpg 美团外卖店铺结构.pngГлядя на предыдущие динамические картинки, мы знаем, что,TabBarСодержимое ниже (то есть на структурной схемеBodyчасть) расширяется по мере того, как страница скользит вверх, а также включает в себя скользящий компонент внутри. Глядя на эту структуру, мы можем легко думать о ней.NestedScrollViewэтот компонент. но использовать напрямуюNestedScrollViewесть некоторые проблемы. Например, сначала посмотрите на пример кода:

  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool boxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              pinned: true,
              title: Text("首页",style: TextStyle(color: Colors.black)),
              backgroundColor: Colors.transparent,
              bottom: TabBar(
                controller: _tabController,
                labelColor: Colors.black,
                tabs: <Widget>[
                  Tab(text: "商品"),
                  Tab(text: "评价"),
                  Tab(text: "商家"),
                ],
              ),
            )
          ];
        },
        body: Container(
          color: Colors.blue,
          child: Center(
            child: Text("Body部分"),
          ),
        ),
      ),
    );
  }

NestedScrollView问题.gifГлядя на код, яSliverAppBarФон установлен на прозрачный. Проблема возникает, когда страница прокручивается, часть тела проходитSliverAppBarа также状态栏ниже, в верхнюю часть экрана. В этом случае эффект определенно не тот, который нам нужен. Кроме того, посколькуNestedScrollViewВнутри только одинScrollController(в коде нижеinnerController),BodyВсе списки внутриScrollPositionбудетattachк этомуScrollController, то есть другая проблема, наша商品На странице есть два списка.Если контроллер общий, тоScrollPositionтоже использовать один и тот же, что не приемлемо, ведь списки разные, так ведьNestedScrollViewВнутри только одинScrollControllerЭто то, что определяет, что мы не можем полагаться наNestedScrollViewдля достижения этого эффекта. но,NestedScrollViewЭто не бесполезно для нас, но дает нам ключевые идеи. зачем говоритьNestedScrollViewВсе еще полезны для нас? Благодаря своим характеристикам,BodyРаздел будет расширяться по мере продвижения страницы вверх,BodyНижняя часть раздела всегда находится внизу экрана. тогда этоBodyОткуда взялась высота сечения? пойдем посмотримNestedScrollViewкод:

  List<Widget> _buildSlivers(BuildContext context,
      ScrollController innerController, bool bodyIsScrolled) {
    return <Widget>[
      ...headerSliverBuilder(context, bodyIsScrolled),
      SliverFillRemaining(
        child: PrimaryScrollController(
          controller: innerController,
          child: body,
        ),
      ),
    ];
  }

NestedScrollViewизbodyположи этоSliverFillRemaining, и этоSliverFillRemainingДа, в самом делеNestedScrollViewизbodyв состоянии заполнить передний компонент вNestedScrollViewключ между днищами. Хорошо, зная, что этот парень существует, мы можем попытаться сделать продолжение.NestedScrollViewНекоторые подобные эффекты. Я выбрал крайний скользящий компонентCustomScrollView,Эй-эй,NestedScrollViewтакже унаследовано отCustomScrollViewбыть реализованным.

Реализовать аналогичный эффект NestedScrollView

Сначала мы пишемNestedScrollViewСтруктурно похожий интерфейсShopPageВыходите, код ключа следующий:

class _ShopPageState extends State<ShopPage>{
    
    @override
    Widget build(BuildContext context) {
        return Scaffold(
          body: CustomScrollView(
            controller: _pageScrollController,
            physics: AlwaysScrollableScrollPhysics(),
            slivers: <Widget>[
              SliverAppBar(
                  pinned: true,
                  title: Text("店铺首页", style: TextStyle(color: Colors.white)),
                  backgroundColor: Colors.blue,
                  expandedHeight: 300),
              SliverFillRemaining(
                  child: ListView.builder(
                      controller: _childScrollController,
                      padding: EdgeInsets.all(0),
                      physics: AlwaysScrollableScrollPhysics(),
                      shrinkWrap: true,
                      itemExtent: 100.0,
                      itemCount: 30,
                      itemBuilder: (context, index) => Container(
                          padding: EdgeInsets.symmetric(horizontal: 1),
                          child: Material(
                            elevation: 4.0,
                            borderRadius: BorderRadius.circular(5.0),
                            color:
                            index % 2 == 0 ? Colors.cyan : Colors.deepOrange,
                            child: Center(child: Text(index.toString())),
                          ))))
            ],
          ),
        );
    }
}


               页面结构                           滑动效果

类NestedScrollView实现1.jpg 类NestedScrollView实现2.gifКак видно из анимации, сдвиньте следующееListViewне могу водитьCustomScrollViewсерединаSliverAppBarтелескопический. Как мы должны достичь этого? Сначала подумайте об эффекте, который мы хотим:

  • Проведите вверхListViewкогда, еслиSliverAppBarэто расширенное состояние, оно должно бытьSliverAppBarсжиматься, когдаSliverAppBarКогда не в силах сжаться,ListViewбудет прокручиваться.
  • проведите внизListViewкогда когдаListViewКогда он был перенесен на первый и больше не может быть перенесен,SliverAppBarдолжен расширяться доSliverAppBarполностью расширен.

SliverAppBarДолжен ли он реагировать, расширяться или сжиматься в ответ. Нам обязательно нужно滑动方向а такжеCustomScrollView与ListView已滑动距离судить. Итак, нам нужен инструмент для滑动事件是谁发起的、CustomScrollView与ListView的状态、滑动的方向、滑动的距离、滑动的速度и так далее, чтобы координировать их реакцию.

Насчет того, как писать этому координатору, мы не торопимся. Следует разобраться с принципом раздвижных элементов, рекомендуемые статьи:

Реализовать вложенный скользящий PageView с нуля (1)

Реализовать вложенный скользящий PageView с нуля (2)

Реализовать вложенный скользящий PageView с нуля (3)

Ограничения прокрутки и осколков во Flutter

После прочтения этих статей в сочетании с нашими сценариями использования нам необходимо понять:

  • Когда ваш палец скользит по экрану,ScrollerPositionсерединаapplyUserOffsetМетод получит скользящий вектор;
  • Когда ваш палец покидает экран,ScrollerPositionсерединаgoBallisticМетод получит скорость скольжения до того, как палец покинет экран;
  • От начала до конца скользящие события, инициированные на основном скользящем компоненте, не мешают подчиненным скользящим компонентам, поэтому при координации нам нужно только передать события подкомпонентов координатору для анализа и согласования.

Проще говоря, нам нужно изменитьScrollerPosition, ScrollerController. ИсправлятьScrollerPositionэто поставить手指滑动距离или手指离开屏幕前滑动速度Передано координатору для согласования обработки. ИсправлятьScrollerControllerзаключается в том, чтобы убедиться, что скользящий контроллер созданScrollerPositionСоздан наш модифицированныйScrollerPosition. Итак, приступим!

Реализуйте подкомпонент, скользящий вверх и вниз, чтобы связать основной компонент

Во-первых, предположим, что наш класс координатора называетсяShopScrollCoordinator.

Раздвижной контроллер

мы идем копироватьScrollerControllerИсходный код , а затем для удобства различения меняем имя класса наShopScrollController. Части контроллера, которые необходимо изменить, следующие:

class ShopScrollController extends ScrollController {
  final ShopScrollCoordinator coordinator;

  ShopScrollController(
    this.coordinator, {
    double initialScrollOffset = 0.0,
    this.keepScrollOffset = true,
    this.debugLabel,
  })  : assert(initialScrollOffset != null),
        assert(keepScrollOffset != null),
        _initialScrollOffset = initialScrollOffset;

  ScrollPosition createScrollPosition(ScrollPhysics physics,
      ScrollContext context, ScrollPosition oldPosition) {
    return ShopScrollPosition(
      coordinator: coordinator,
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }
  ///其他的代码不要动
}

Скользящая позиция прокрутки ShopScrollPosition

оригинальныйScrollerControllerсозданныйScrollPositionдаScrollPositionWithSingleContext. мы идем копироватьScrollPositionWithSingleContextИсходный код , а затем для удобства различения меняем имя класса наShopScrollPosition. Как упоминалось ранее, нам в основном нужно изменитьapplyUserOffset,goBallisticдва метода.

class ShopScrollPosition extends ScrollPosition 
    implements ScrollActivityDelegate {
  final ShopScrollCoordinator coordinator; // 协调器

  ShopScrollPosition(
      {@required this.coordinator,
      @required ScrollPhysics physics,
      @required ScrollContext context,
      double initialPixels = 0.0,
      bool keepScrollOffset = true,
      ScrollPosition oldPosition,
      String debugLabel})
      : super(
          physics: physics,
          context: context,
          keepScrollOffset: keepScrollOffset,
          oldPosition: oldPosition,
          debugLabel: debugLabel,
       ) {
    if (pixels == null && initialPixels != null) correctPixels(initialPixels);
    if (activity == null) goIdle();
    assert(activity != null);
  }

  /// 当手指滑动时,该方法会获取到滑动距离
  /// [delta]滑动距离,正增量表示下滑,负增量向上滑
  /// 我们需要把子部件的 滑动数据 交给协调器处理,主部件无干扰
  @override
  void applyUserOffset(double delta) {
    ScrollDirection userScrollDirection =
        delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse;
    if (debugLabel != coordinator.pageLabel)
      return coordinator.applyUserOffset(delta, userScrollDirection, this);
    updateUserScrollDirection(userScrollDirection);
    setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
  }

  /// 以特定的速度开始一个物理驱动的模拟,该模拟确定[pixels]位置。
  /// 此方法遵从[ScrollPhysics.createBallisticSimulation],该方法通常在当前位置超出
  /// 范围时提供滑动模拟,而在当前位置超出范围但具有非零速度时提供摩擦模拟。
  /// 速度应以每秒逻辑像素为单位。 
  /// [velocity]手指离开屏幕前滑动速度,正表示下滑,负向上滑
  @override
  void goBallistic(double velocity, [bool fromCoordinator = false]) {
    if (debugLabel != coordinator.pageLabel) {
      // 子部件滑动向上模拟滚动时才会关联主部件
      if (velocity > 0.0) coordinator.goBallistic(velocity);
    } else {
      if (fromCoordinator && velocity <= 0.0) return;
    }
    assert(pixels != null);
    final Simulation simulation =
        physics.createBallisticSimulation(this, velocity);
    if (simulation != null) {
      beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
    } else {
      goIdle();
    }
  }

  /// 返回未使用的增量。 
  /// 从[NestedScrollView]的自定义[ScrollPosition][_NestedScrollPosition]拷贝
  double applyClampedDragUpdate(double delta) {
    assert(delta != 0.0);
    final double min =
        delta < 0.0 ? -double.infinity : math.min(minScrollExtent, pixels);
    final double max =
        delta > 0.0 ? double.infinity : math.max(maxScrollExtent, pixels);
    final double oldPixels = pixels;
    final double newPixels = (pixels - delta).clamp(min, max) as double;
    final double clampedDelta = newPixels - pixels;
    if (clampedDelta == 0.0) return delta;
    final double overScroll = physics.applyBoundaryConditions(this, newPixels);
    final double actualNewPixels = newPixels - overScroll;
    final double offset = actualNewPixels - oldPixels;
    if (offset != 0.0) {
      forcePixels(actualNewPixels);
      didUpdateScrollPositionBy(offset);
    }
    return delta + offset;
 }

 /// 返回过度滚动。
 /// 从[NestedScrollView]的自定义[ScrollPosition][_NestedScrollPosition]拷贝
 double applyFullDragUpdate(double delta) {
   assert(delta != 0.0);
   final double oldPixels = pixels;
   // Apply friction: 施加摩擦:
   final double newPixels =
       pixels - physics.applyPhysicsToUserOffset(this, delta);
   if (oldPixels == newPixels) return 0.0;
   // Check for overScroll: 检查过度滚动:
   final double overScroll = physics.applyBoundaryConditions(this, newPixels);
   final double actualNewPixels = newPixels - overScroll;
   if (actualNewPixels != oldPixels) {
     forcePixels(actualNewPixels);
     didUpdateScrollPositionBy(actualNewPixels - oldPixels);
   }
   return overScroll;
 }
}

Раздвижной координатор ShopScrollCoordinator

class ShopScrollCoordinator {
  /// 页面主滑动组件标识
  final String pageLabel = "page";
  
  /// 获取主页面滑动控制器
  ShopScrollController pageScrollController([double initialOffset = 0.0]) {
    assert(initialOffset != null, initialOffset >= 0.0);
    _pageInitialOffset = initialOffset;
    _pageScrollController = ShopScrollController(this,
        debugLabel: pageLabel, initialScrollOffset: initialOffset);
  return _pageScrollController;
  }
  /// 创建并获取一个子滑动控制器
  ShopScrollController newChildScrollController([String debugLabel]) =>
      ShopScrollController(this, debugLabel: debugLabel);

  /// 子部件滑动数据协调
  /// [delta]滑动距离
  /// [userScrollDirection]用户滑动方向
  /// [position]被滑动的子部件的位置信息
  void applyUserOffset(double delta,
      [ScrollDirection userScrollDirection, ShopScrollPosition position]) {
    if (userScrollDirection == ScrollDirection.reverse) {
      /// 当用户滑动方向是向上滑动
      updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
      final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta);
      if (innerDelta != 0.0) {
        updateUserScrollDirection(position, userScrollDirection);
        position.applyFullDragUpdate(innerDelta);
      }
    } else {
      /// 当用户滑动方向是向下滑动
      updateUserScrollDirection(position, userScrollDirection);
      final outerDelta = position.applyClampedDragUpdate(delta);
      if (outerDelta != 0.0) {
        updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
        _pageScrollPosition.applyFullDragUpdate(outerDelta);
      }
    }
  }
}

Теперь мы_ShopPageStateДобавьте код в:

class _ShopPageState extends State<ShopPage>{
  // 页面滑动协调器
  ShopScrollCoordinator _shopCoordinator;
  // 页面主滑动部件控制器
  ShopScrollController _pageScrollController;
  // 页面子滑动部件控制器
  ShopScrollController _childScrollController;
    
  /// build 方法中的CustomScrollView和ListView 记得加上控制器!!!!

  @override
  void initState() {
    super.initState();
    _shopCoordinator = ShopScrollCoordinator();
    _pageScrollController = _shopCoordinator.pageScrollController();
    _childScrollController = _shopCoordinator.newChildScrollController();
  }

  @override
  void dispose() {
    _pageScrollController?.dispose();
    _childScrollController?.dispose();
    super.dispose();
  }
}

В настоящее время в основном понимается, что подкомпонент скользит вверх и вниз, чтобы ассоциироваться с основным компонентом. Эффект как показано:实现子部件上下滑动关联主部件.gif

Реализовать структуру тела на странице заказов Meituan на вынос

Исправлять_ShopPageStateсерединаSliverFillRemainingСодержание:

/// 注意添加一个新的控制器!!

SliverFillRemaining(
              child: Row(
            children: <Widget>[
              Expanded(
                  child: ListView.builder(
                      controller: _childScrollController,
                      padding: EdgeInsets.all(0),
                      physics: AlwaysScrollableScrollPhysics(),
                      shrinkWrap: true,
                      itemExtent: 50,
                      itemCount: 30,
                      itemBuilder: (context, index) => Container(
                          padding: EdgeInsets.symmetric(horizontal: 1),
                          child: Material(
                            elevation: 4.0,
                            borderRadius: BorderRadius.circular(5.0),
                            color: index % 2 == 0
                                ? Colors.cyan
                                : Colors.deepOrange,
                            child: Center(child: Text(index.toString())),
                          )))),
              Expanded(
                  flex: 4,
                  child: ListView.builder(
                      controller: _childScrollController1,
                      padding: EdgeInsets.all(0),
                      physics: AlwaysScrollableScrollPhysics(),
                      shrinkWrap: true,
                      itemExtent: 150,
                      itemCount: 30,
                      itemBuilder: (context, index) => Container(
                          padding: EdgeInsets.symmetric(horizontal: 1),
                          child: Material(
                            elevation: 4.0,
                            borderRadius: BorderRadius.circular(5.0),
                            color: index % 2 == 0
                                ? Colors.cyan
                                : Colors.deepOrange,
                            child: Center(child: Text(index.toString())),
                          ))))
            ],
          ))

увидеть эффект美团外卖点菜页面实现1.gifКажется, что есть еще некоторые проблемы, в чем проблема? Когда я только прокручиваю правый подкомпонент, когдаSliverAppBarПри минимизации мы видим, что первая из левых подкомпонент не равна 0. Как показано на рисунке:SliverFillRemaining穿过吸顶组件问题.jpgс предыдущимNestedScrollViewтакая же проблема в . Итак, как мы ее решим? Измени это!Вдохновленный Flutter CandiesМетод добавления координатора:

/// 获取body前吸顶组件高度
double Function() pinnedHeaderSliverHeightBuilder;

bool applyContentDimensions(double minScrollExtent, double maxScrollExtent,
    ShopScrollPosition position) {
  if (pinnedHeaderSliverHeightBuilder != null) {
    maxScrollExtent = maxScrollExtent - pinnedHeaderSliverHeightBuilder();
    maxScrollExtent = math.max(0.0, maxScrollExtent);
  }
  return position.applyContentDimensions(
      minScrollExtent, maxScrollExtent, true);
}

ИсправлятьShopScrollPositionизapplyContentDimensionsметод:

@override
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent,
    [bool fromCoordinator = false]) {
  if (debugLabel == coordinator.pageLabel && !fromCoordinator)
    return coordinator.applyContentDimensions(
        minScrollExtent, maxScrollExtent, this);
  return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
}

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

Реализовать эффект полноэкранного расширения заголовка страницы магазина на вынос Meituan для отображения информации о магазине.

Цель заключается в следующем:美团外卖头部全屏化展开显示店铺信息效果.gifПочему вы говорите, что это полноэкранный режим? Мне не нужно больше говорить об этом. Серый цвет вокруг расширенной карты - этоpaddingВот и все. использовалSliverAppBarв принципе любой может подумать об этом,expandedHeightУстановка его на высоту экрана позволяет голове заполнять весь экран, когда он расширяется. Однако на страницеSliverAppBarПо умолчанию это не полностью развернутое состояние, и, конечно же, это не полностью убранное состояние.В полностью убранном состоянии у этой штуки будет только панель приложений вверху. Итак, как нам сделать так, чтобы по умолчанию он отображался как Meituan? помни нашиScrollControllerКонструктор имеет имяinitialScrollOffsetВы можете передавать параметры, хе-хе, пока мы устанавливаем контроллер основной скользящей части страницы.initialScrollOffset, страница по умолчанию будетinitialScrollOffsetсоответствующее место. Хорошо, расположение по умолчанию в порядке. Однако, как видно из анимации, когда мы опускаем компонент,默认位置 < 主部件已下滑距离 < 最大展开高度и отпусти палец,SliverAppBarбудет продолжать расширяться до最大展开高度. Затем мы должны зафиксировать событие, когда палец покидает экран. В это время мы можем использоватьListenerупаковка компонентовCustomScrollView, затем вListenerизonPointerUpСобытие «Убери палец с экрана» в . Хорошо, вот идея. Давайте посмотрим, как это сделать:

Добавьте перечисление вне координатора:

enum PageExpandState { NotExpand, Expanding, Expanded }

Код добавления координатора:

/// 主页面滑动部件默认位置
double _pageInitialOffset;

/// 获取主页面滑动控制器
ShopScrollController pageScrollController([double initialOffset = 0.0]) {
    assert(initialOffset != null, initialOffset >= 0.0);
    _pageInitialOffset = initialOffset;
    _pageScrollController = ShopScrollController(this,
        debugLabel: pageLabel, initialScrollOffset: initialOffset);
  return _pageScrollController;
}

/// 当默认位置不为0时,主部件已下拉距离超过默认位置,但超过的距离不大于该值时,
/// 若手指离开屏幕,主部件头部会回弹至默认位置
double _scrollRedundancy = 80;

/// 当前页面Header最大程度展开状态
PageExpandState pageExpand = PageExpandState.NotExpand;    

/// 当手指离开屏幕
void onPointerUp(PointerUpEvent event) {
  final double _pagePixels = _pageScrollPosition.pixels;
  if (0.0 < _pagePixels && _pagePixels < _pageInitialOffset) {
    if (pageExpand == PageExpand.NotExpand &&
        _pageInitialOffset - _pagePixels > _scrollRedundancy) {
      _pageScrollPosition
          .animateTo(0.0,
              duration: const Duration(milliseconds: 400), curve: Curves.ease)
          .then((value) => pageExpand = PageExpand.Expanded);
    } else {
      pageExpand = PageExpand.Expanding;
      _pageScrollPosition
          .animateTo(_pageInitialOffset,
              duration: const Duration(milliseconds: 400), curve: Curves.ease)
          .then((value) => pageExpand = PageExpand.NotExpand);
    }
  }
}

В это время мы ставим координатораonPointerUpметод переданListenerизonPointerUp, мы в принципе добились желаемого эффекта. Но, после тестирования, на самом деле у него есть небольшая проблема.Иногда при отпускании пальца он не будет автоматически расширяться или возвращаться в положение по умолчанию, как мы себе представляли. В чем проблема? Мы знаем, что когда палец проводит по списку, а затем покидает экран,ScrollPositionизgoBallisticбудет вызван метод, поэтомуonPointerUpтолько что позвонил сразуgoBallisticтакже называется, когдаgoBallisticКогда абсолютное значение входящей скорости мало, моделируемое расстояние скольжения списка очень мало, даже 0,0. Так каков результат, он естественным образом возникнет в моей голове.

Нам все еще нужно продолжать модифицироватьShopScrollPositionизgoBallisticметод:

@override
void goBallistic(double velocity, [bool fromCoordinator = false]) {
  if (debugLabel != coordinator.pageLabel) {
    if (velocity > 0.0) coordinator.goBallistic(velocity);
  } else {
    if (fromCoordinator && velocity <= 0.0) return;
    if (coordinator.pageExpand == PageExpandState.Expanding) return;
  }
  assert(pixels != null);
  final Simulation simulation =
      physics.createBallisticSimulation(this, velocity);
  if (simulation != null) {
    beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
  } else {
    goIdle();
  }
}

запомнить страницуinitState, инициализировать_pageScrollControllerКогда , не забудьте передать значение местоположения по умолчанию. На данный момент следует отметить, что значение позиции по умолчанию не является страницей в состоянии по умолчанию.SliverAppBarНиз - это расстояние от верха экрана, а высота экрана минус расстояние его низа от верха экрана, т.е.initialOffset = screenHeight - x, и этоxМы устанавливаем его в соответствии с дизайном или собственным ощущением. Вот беру 200. Давай, посмотрим, как это работает! !头部全屏化展开显示店铺信息实现.gif

Ссылка на кейс статьи на githubflutter_meituan_shop