Во-первых, давайте посмотрим на цели и последствия
Я поставил место для деятельности здесь.TabBar
над. А почему, ха-ха, боюсь неприятностей, потому что активные компоненты Meituan Takeaway сочетаются с компонентами продуктов, указанных ниже.点菜
,评价
,商家
Он исчезает при переключении страницы, но эта штука исчезает при скольжении страницы продукта вверх.Включая основной компонент скольжения, мы должны сделать так, чтобы скольжение из компонента списка продуктов проходило через два уровня, что действительно хлопотно. Поэтому я положил активный компонент вTabBar
над.
Затем давайте проанализируем структуру страницы
Глядя на предыдущие динамические картинки, мы знаем, что,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部分"),
),
),
),
);
}
Глядя на код, я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())),
))))
],
),
);
}
}
页面结构 滑动效果
Как видно из анимации, сдвиньте следующее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();
}
}
В настоящее время в основном понимается, что подкомпонент скользит вверх и вниз, чтобы ассоциироваться с основным компонентом. Эффект как показано:
Реализовать структуру тела на странице заказов 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())),
))))
],
))
увидеть эффектКажется, что есть еще некоторые проблемы, в чем проблема? Когда я только прокручиваю правый подкомпонент, когдаSliverAppBar
При минимизации мы видим, что первая из левых подкомпонент не равна 0. Как показано на рисунке:с предыдущим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 для отображения информации о магазине.
Цель заключается в следующем:Почему вы говорите, что это полноэкранный режим? Мне не нужно больше говорить об этом. Серый цвет вокруг расширенной карты - это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.
Давай, посмотрим, как это работает! !
Ссылка на кейс статьи на githubflutter_meituan_shop