以下代码基本参考于 flutter_gallery中的animation_demo示例。(可以结合本文看源码)
题外话:这个demo是最炫酷的了
这里的动画效果我们看到:
- 有一个多页的滚动
- 滑到上下滑到将近一半,会有一个粘性效果,吸附到一半。再往上,就正常滑动。
3.一半往上,下面的白色标签开始发生位移。一半往下,整个4个卡片发生位移。
简单的分析一下
上下滚动,并且自定义动画效果。嗯。上一遍文章的CustomScrollView
左右滚动,切换页面。嗯。PageView。
PageView可以让像是一页一页滑动。而且每个页面的大小是一样的。
使用PageController
来进行控制。上下要同时切换。肯定也需要上下两个
PageView
的状态同步。
第一次接触
先准备好数据。查看sections.dart。可以不管,先复制过来。
初始化布局。
像是大体想象的框架应该是CustomScrollView
.然后初始的SliveAppBar的高度应该是屏幕的高度。SliveAppBar的child是PageView
下面是一个SliveToBoxAdapter里面也放着PageView.代码
按照我们初步的想法,代码如下
import 'package:flutter/material.dart';
import 'package:flutter_start/demo/animation/sections.dart';
Color _kAppBackgroundColor = const Color(0xFF353662);
Duration _kScrollDuration = const Duration(milliseconds: 400);
Curve _kScrollCurve = Curves.fastOutSlowIn;
class AnimationDemoHome extends StatefulWidget {
const AnimationDemoHome({Key key}) : super(key: key);
static const String routeName = '/animation';
@override
_AnimationDemoHomeState createState() => new _AnimationDemoHomeState();
}
class _AnimationDemoHomeState extends State<AnimationDemoHome> {
@override
Widget build(BuildContext context) {
return new Scaffold(
backgroundColor: _kAppBackgroundColor,
body: new Builder(
// Insert an element so that _buildBody can find the PrimaryScrollController.
builder: _buildBody,
),
);
}
Widget _buildBody(BuildContext context) {
double height = MediaQuery.of(context).size.height;
return new SizedBox.expand(
child: new Stack(
children: <Widget>[
new CustomScrollView(
slivers: <Widget>[
SliverAppBar(
expandedHeight: height,
flexibleSpace: LayoutBuilder(builder:
(BuildContext context, BoxConstraints constraints) {
return PageView(
children: allSections.map((Section section) {
return _headerItemsFor(section);
}).toList(),
);
}),
),
SliverToBoxAdapter(
child: SizedBox(
height: 610.0,
child: PageView(
children: allSections.map((Section section) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _detailItemsFor(section).toList(),
);
}).toList(),
),
),
),
],
),
],
),
);
}
Iterable<Widget> _detailItemsFor(Section section) {
final Iterable<Widget> detailItems =
section.details.map((SectionDetail detail) {
return new SectionDetailView(detail: detail);
});
return ListTile.divideTiles(context: context, tiles: detailItems);
}
Widget _headerItemsFor(Section section) {
return SectionCard(section: section);
}
}
class SectionDetailView extends StatelessWidget {
SectionDetailView({Key key, @required this.detail})
: assert(detail != null && detail.imageAsset != null),
assert((detail.imageAsset ?? detail.title) != null),
super(key: key);
final SectionDetail detail;
@override
Widget build(BuildContext context) {
final Widget image = new DecoratedBox(
decoration: new BoxDecoration(
borderRadius: new BorderRadius.circular(6.0),
image: new DecorationImage(
image: new AssetImage(
detail.imageAsset,
package: detail.imageAssetPackage,
),
fit: BoxFit.cover,
alignment: Alignment.center,
),
),
);
Widget item;
if (detail.title == null && detail.subtitle == null) {
item = new Container(
height: 240.0,
padding: const EdgeInsets.all(16.0),
child: new SafeArea(
top: false,
bottom: false,
child: image,
),
);
} else {
item = new ListTile(
title: new Text(detail.title),
subtitle: new Text(detail.subtitle),
leading: new SizedBox(width: 32.0, height: 32.0, child: image),
);
}
return new DecoratedBox(
decoration: new BoxDecoration(color: Colors.grey.shade200),
child: item,
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({Key key, @required this.section})
: assert(section != null),
super(key: key);
final Section section;
@override
Widget build(BuildContext context) {
return new Semantics(
label: section.title,
button: true,
child: new DecoratedBox(
decoration: new BoxDecoration(
gradient: new LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
section.leftColor,
section.rightColor,
],
),
),
child: new Image.asset(
section.backgroundAsset,
package: section.backgroundAssetPackage,
color: const Color.fromRGBO(255, 255, 255, 0.075),
colorBlendMode: BlendMode.modulate,
fit: BoxFit.cover,
),
),
);
}
}
- 效果
发现我们的想法还是有一定偏差的。上面的头部部分,不只是pageView
,它需要从一个list
然后移动变成pageView
.
CustomMultiChildLayout
这个Widget
可以完全自己掌控布局的排列。我们需要做的是将它的自组件都传递给他,然后实现它的方法,就可以完全的掌握自己的布局了。
使用它有两个关键点:
- 自定义
MultiChildLayoutDelegate
来自己实现布局 - 他的每个child都需要用layoutId来包裹,并且分配给他们的id,都必须是唯一的。
按照这个思路,我们希望每一个Page
都是能实现这个样的动画效果,所以我们自己定义CustomMultiChildLayout
作为PageView的child。
同时,我们还需要将之前的4个SectionsCard
用LayoutId包裹后,传入其中。
- 自定义实现
MultiChildLayoutDelegate
class _AllSectionsLayout extends MultiChildLayoutDelegate {
int cardCount = 4;
double selectedIndex = 0.0;
double tColumnToRow = 0.0;
///Alignment(-1.0, -1.0) 表示矩形的左上角。
///Alignment(1.0, 1.0) 代表矩形的右下角。
Alignment translation = new Alignment(0 * 2.0 - 1.0, -1.0);
_AllSectionsLayout({this.tColumnToRow,this.selectedIndex,this.translation});
@override
void performLayout(Size size) {
//初始值
//竖向布局时
//卡片的left
final double columnCardX = size.width / 5.0;
//卡片的宽度Width
final double columnCardWidth = size.width - columnCardX;
//卡片的高度
final double columnCardHeight = size.height / cardCount;
//横向布局时
final double rowCardWidth = size.width;
final Offset offset = translation.alongSize(size);
double columnCardY = 0.0;
double rowCardX = -(selectedIndex * rowCardWidth);
for (int index = 0; index < cardCount; index++) {
// Layout the card for index.
final Rect columnCardRect = new Rect.fromLTWH(
columnCardX, columnCardY, columnCardWidth, columnCardHeight);
final Rect rowCardRect =
new Rect.fromLTWH(rowCardX, 0.0, rowCardWidth, size.height);
// 定义好初始的位置和结束的位置,就可以使用这个lerp函数,轻松的找到中间状态值
//rect 的 shift ,相当于 offset的translate
final Rect cardRect =
_interpolateRect(columnCardRect, rowCardRect).shift(offset);
final String cardId = 'card$index';
if (hasChild(cardId)) {
layoutChild(cardId, new BoxConstraints.tight(cardRect.size));
positionChild(cardId, cardRect.topLeft);
}
columnCardY += columnCardHeight;
rowCardX += rowCardWidth;
}
}
@override
bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) {
print('oldDelegate=$oldDelegate');
return false;
}
Rect _interpolateRect(Rect begin, Rect end) {
return Rect.lerp(begin, end, tColumnToRow);
}
Offset _interpolatePoint(Offset begin, Offset end) {
return Offset.lerp(begin, end, tColumnToRow);
}
}
定义整个动画过程
整个动画效果就是,从竖排的4列,变化成横排的4列。
为每个card
定义好
动画的初始
card
的初始状态column
为前缀的变量。
- 高度
就是按照我们看到的,竖排的情况下,每个Card的高度是整个appBar高度的4分之一。 - left
统一的位置。 - 宽度
去掉left部分的,宽度 - Offset
Offset需要确定的位置,需要和选定的坐标协同。选定的Index,毕竟出现在当前位置。就是他的Offset的x,必须和自己的left相反,这样才能在第一个位置。
它是用Aligment.alongSize
来进行转换。Alignment(-1.0, -1.0)
就代表左上角。Alignment(1.0, 1.0)
代表矩形的右下角。整个Aligment
相当于一个边长为2,中心点在原点的正方形。
需要让index== selectedIndex的card的Aligment为左上角Alignment(1.0, 1.0)
的状态。然后其他对应的进行偏移。
动画的结尾
card
的最终状态row
为前缀的变量
高度
就是整个的高度left
就是选中card的偏移量。宽度
就是整个的宽度offset
同上。
确定中间状态
-
tColumnToRow
整体的动画,在Flutter中有很方便的lerp
函数可以确定中间的状态。只要传入我们进度的百分比就可以。这个百分比可以由滑动的过程中的offset传入。
SliverAppBar
//只显示sliverAppBar部分
slivers: <Widget>[
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handleScrollNotification(
notification, appBarMidScrollOffset);
},
child: SliverAppBar(
backgroundColor: _kAppBackgroundColor,
expandedHeight: height - statusHeight,
bottom: PreferredSize(
preferredSize:
const Size.fromHeight(_kAppBarMinHeight - kToolbarHeight),
child: Container(width: 0.0, height: 0.0),
),
pinned: true,
//同样根据上一节我们学习到的内容,我们可以通过layoutbuilder来获取变化的约束
flexibleSpace: LayoutBuilder(builder:
(BuildContext context, BoxConstraints constraints) {
//因为发现当滚动成column时,上面有statusBar高度的padding,当变成row时,整个padding就变成0,所以这里是这个的变化值
double t =
1.0 - (height - constraints.maxHeight) / (height * 0.3);
final Curve statusBarHeightCurve =
Interval(0.0, 1.0, curve: Curves.fastOutSlowIn);
double extraPaddingTop =
statusHeight * statusBarHeightCurve.transform(t.clamp(0.0, 1.0));
//这里开始计算 tColumnToRow的比例。其实就是滚动的距离。
final Size size = constraints.biggest;
final double tColumnToRow = 1.0 -
((size.height - _kAppBarMidHeight) /
(height - statusHeight - _kAppBarMidHeight))
.clamp(0.0, 1.0);
final List<Widget> sectionCards = <Widget>[];
for (int index = 0; index < allSections.length; index++) {
Section section = allSections[index];
sectionCards.add(_headerItemsFor(section));
}
List<Widget> children = [];
for (int index = 0; index < sectionCards.length; index++) {
//这里一定要注意, CustomMultiChildLayout中的,子节点,都必须用LayoutId来包裹!!!
children.add(new LayoutId(
id: 'card$index',
child: sectionCards[index],
));
}
List<Widget> layoutChildren = [];
print('selectedIndex.value=${selectedIndex.value}');
for (int index = 0; index < sectionCards.length; index++) {
layoutChildren.add(new CustomMultiChildLayout(
delegate: _AllSectionsLayout(
tColumnToRow: tColumnToRow,
translation: new Alignment(
(selectedIndex.value - index) * 2.0 - 1.0, -1.0),
selectedIndex: selectedIndex.value
),
children: children,
));
}
//将上面的用PageView再包裹一次。
return Padding(
padding: EdgeInsets.only(top: extraPaddingTop),
child: PageView(
physics: _headingScrollPhysics,
controller: _headingPageController,
children: layoutChildren,
),
),
);
}),
),
),
上面这段代码,有下面几个重点
SliverAppBar的bottom
因为我们使用Pinned属性。这个属性会悬浮我们的AppBar在顶部。但是如果默认情况下,这时appBar的高度就是有56逻辑像素这样。所以,我们需要添加一个bottom,让它,增加到我们想要的最后高度。调整整体的padding
从动画效果可以看到,padding有一个从有到无的状态,当从column变成row的过程中,所以我们要对其进行计算。计算
tColumnToRow
这个值也是根据我们滑动的整体状态来计算的。LayoutId
这个一定要记住!
CustomMultiChildLayout中的,子节点,都必须用LayoutId来包裹!!!
然后,我还要处理两个细节。
一个是当滚动到中间位置后,就不能左右切换了。
- 监听
将NotificationListener包裹在pageView之外,就可以监听PageView的滚动事件了。
//省略代码...
NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
return _handlePageNotification(notification,
_headingPageController, _detailsPageController);
},
child: Padding(
padding: EdgeInsets.only(top: extraPaddingTop),
child: PageView(
physics: _headingScrollPhysics,
controller: _headingPageController,
children: layoutChildren,
),
),
);
- 切换
这个需要监听,滚动的事件,当滚动的距离得到一般之后,就将PageView的physics
改为NeverScrollableScrollPhysics
。它将会导致页面不能滚动。
反之,就设置为PageScrollPhysics()
.像页面一样滚动。
bool _handleScrollNotification(
ScrollNotification notification, double midScrollOffset) {
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
final ScrollPhysics physics =
_scrollController.position.pixels >= midScrollOffset
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics();
if (physics != _headingScrollPhysics) {
setState(() {
_headingScrollPhysics = physics;
});
}
}
return false;
}
当快滚动中间位置时,会有一个粘性的效果
这个效果是整个SliverAppBar来提供的。所以设置他的physics
。
当滚动的距离大于一办时,判断对应的滚动反向,来创造对应simulation
class _SnappingScrollPhysics extends ClampingScrollPhysics {
const _SnappingScrollPhysics({
ScrollPhysics parent,
@required this.midScrollOffset,
}) : assert(midScrollOffset != null),
super(parent: parent);
final double midScrollOffset;
@override
_SnappingScrollPhysics applyTo(ScrollPhysics ancestor) {
return new _SnappingScrollPhysics(
parent: buildParent(ancestor), midScrollOffset: midScrollOffset);
}
Simulation _toMidScrollOffsetSimulation(double offset, double dragVelocity) {
final double velocity = math.max(dragVelocity, minFlingVelocity);
return new ScrollSpringSimulation(spring, offset, midScrollOffset, velocity,
tolerance: tolerance);
}
Simulation _toZeroScrollOffsetSimulation(double offset, double dragVelocity) {
final double velocity = math.max(dragVelocity, minFlingVelocity);
return new ScrollSpringSimulation(spring, offset, 0.0, velocity,
tolerance: tolerance);
}
@override
Simulation createBallisticSimulation(
ScrollMetrics position, double dragVelocity) {
final Simulation simulation =
super.createBallisticSimulation(position, dragVelocity);
final double offset = position.pixels;
if (simulation != null) {
// The drag ended with sufficient velocity to trigger creating a simulation.
// If the simulation is headed up towards midScrollOffset but will not reach it,
// then snap it there. Similarly if the simulation is headed down past
// midScrollOffset but will not reach zero, then snap it to zero.
final double simulationEnd = simulation.x(double.infinity);
if (simulationEnd >= midScrollOffset) return simulation;
if (dragVelocity > 0.0)
return _toMidScrollOffsetSimulation(offset, dragVelocity);
if (dragVelocity < 0.0)
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
} else {
// The user ended the drag with little or no velocity. If they
// didn't leave the offset above midScrollOffset, then
// snap to midScrollOffset if they're more than halfway there,
// otherwise snap to zero.
final double snapThreshold = midScrollOffset / 2.0;
if (offset >= snapThreshold && offset < midScrollOffset)
return _toMidScrollOffsetSimulation(offset, dragVelocity);
if (offset > 0.0 && offset < snapThreshold)
return _toZeroScrollOffsetSimulation(offset, dragVelocity);
}
return simulation;
}
}
运行效果
这样,我们就做成很接近最后效果的动画了。要实现最后的动画,只要用相同的办法去创建title
和indicator
就行了。
总结
虽然我们的代码,和animation_demo源码中的代码有所不一样。但是核心是一样的。
这边文章我们熟悉了
CustomScrollView
的MultiChildLayoutDelegate
通过CustomScrollView
的MultiChildLayoutDelegate
的performLayout
方法的实现,来完成自定义的多组件之间的布局。
自定义动画的过程
自定义动画的过程,在Flutter中其实相对简单。提供了很多帮助的计算方式。需要做的是确定要初始值,和最终值,中间的过度变量可以考虑使用lerp
就可以完成。
监听事件
之前的文章,我们分析过Flutter中数据的传递。需要监听发送的ScrollEvent,我们只要在我们监听的Widget的外层,套一层NotificationListener进行监听就好
ScrollView的要素
我们更加熟悉了ScrollView的两个要素。controller
和physics
。
-
controller
我们可以得到滚动的状态,和控制滚动的情况。 -
physics
滚动的效果。我们可以添加NeverScrollableScrollPhysics
。这样就不滚动了。添加PageScrolPhysics
,这样就是按照页面滚动。添加BounceScrollPhysics
,就实现ios中的弹性滚动了。
好的。这边文章,我们就暂时到这里。
下一遍文章,我们先介绍一个Flutter中整体的视图树,然后回顾一下我们遇到过的组件。