先上效果图

划水、摸鱼、睡觉
August·2020-12-07·377 次阅读
先上效果图
这是一个经典的 SliverAppBar
应用:
CustomScrollView(
physics: BouncingScrollPhysics(),
slivers: [
SliverAppBar(
leading: BackButton(),
backgroundColor: Color.fromARGB(255, 26, 58, 154),
expandedHeight: 400,
pinned: true,
flexibleSpace: MyFlexibleSpaceBar(
title: Text('August'),
),
actions: [
IconButton(
icon: Icon(Icons.search),
onPressed: () {},
)
],
),
SliverList(
delegate: SliverChildListDelegate(List.generate(100, (index) {
return ListTile(
title: Text('$index'),
);
})),
)
],
)
其中的 MyFlexibleSpaceBar
是对 FlexibleSpaceBar
的修改,图中的图片缩放动画的逻辑也是在这个 MyFlexibleSpaceBar 中的。
下面看一下这个 MyFlexibleSpaceBar
代码,挑其中主要的 build
方法:
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final FlexibleSpaceBarSettings settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
assert(
settings != null,
'A FlexibleSpaceBar must be wrapped in the widget returned by FlexibleSpaceBar.createSettings().',
);
final List<Widget> children = <Widget>[];
final double deltaExtent = settings.maxExtent - settings.minExtent;
// 动画触发阈值
final threshold = 0.5;
final threshold2 = 0.75;
// 0.0 -> Expanded
// 1.0 -> Collapsed to toolbar
final double t =
(1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent)
.clamp(0.0, 1.0) as double;
// 0.0 -> Expanded
// 1.0 -> Half Collapsed to toolbar
final double t2 = ((settings.currentExtent - settings.minExtent) /
(deltaExtent * threshold))
.clamp(0.0, 1.0) as double;
final d = 80.0;
final d2 = 45.0;
double height = settings.maxExtent;
final imageWidth = constraints.maxWidth;
final v1 = _getCollapsePadding(threshold, settings);
{
// final double fadeStart =
// math.max(0.0, 1.0 - kToolbarHeight / deltaExtent);
// const double fadeEnd = 1.0;
//
// assert(fadeStart <= fadeEnd);
//
// final double opacity = 1.0 - Interval(fadeStart, fadeEnd).transform(t);
children.add(Positioned(
top: 0,
left: 0,
right: 0,
height: height,
child: Align(
alignment: Alignment.topLeft,
child: UnconstrainedBox(
child: Transform(
transform: Matrix4.translationValues(
0, _getCollapsePadding(t, settings), 0),
child: AnimatedContainer(
width: t > threshold ? (t > threshold2 ? d2 : d) : imageWidth,
height: t > threshold ? (t > threshold2 ? d2 : d) : height,
curve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 200),
margin: t > threshold
? (t > threshold2
? EdgeInsets.only(
top: (settings.minExtent -
kToolbarHeight / 2 -
d2 / 2) +
_getCollapsePadding(1, settings).abs(),
left: 50)
: EdgeInsets.only(
top: deltaExtent / 2 - 16, left: 20))
: EdgeInsets.zero,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(
t > threshold ? d / 2 : 0,
)),
color: Colors.redAccent,
image: DecorationImage(
image: AssetImage('assets/acg.gy_29.webp'),
fit: BoxFit.cover,
),
),
),
),
),
),
));
}
// title
if (widget.title != null) {
final ThemeData theme = Theme.of(context);
Widget title;
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
title = widget.title;
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
title = Semantics(
namesRoute: true,
child: widget.title,
);
break;
}
// StretchMode.fadeTitle
if (widget.stretchModes.contains(StretchMode.fadeTitle) &&
constraints.maxHeight > settings.maxExtent) {
final double stretchOpacity = 1 -
(((constraints.maxHeight - settings.maxExtent) / 100)
.clamp(0.0, 1.0) as double);
title = Opacity(
opacity: stretchOpacity,
child: title,
);
}
final double opacity = settings.toolbarOpacity;
if (opacity > 0.0) {
TextStyle titleStyle = theme.primaryTextTheme.headline6;
titleStyle =
titleStyle.copyWith(color: titleStyle.color.withOpacity(opacity));
final double scaleValue =
Tween<double>(begin: 1.5, end: 1.0).transform(t);
final Alignment titleAlignment = Alignment.bottomLeft;
children.add(Transform(
alignment: titleAlignment,
transform: Matrix4.translationValues(
0,
t > threshold
? Tween(begin: 0.0, end: 32.0).transform(1 - t2)
: 0,
0)
..scale(scaleValue, scaleValue, 1),
child: DefaultTextStyle(
style: titleStyle,
child: AnimatedContainer(
margin: t > threshold
? EdgeInsets.only(left: 108, bottom: 50)
: EdgeInsets.only(left: 16, bottom: 16),
width: constraints.maxWidth / scaleValue,
alignment: titleAlignment,
duration: const Duration(milliseconds: 80),
child: title,
),
),
));
}
}
return ClipRect(child: Stack(children: children));
});
}
MyFlexibleSpaceBar
的 build 方法最终会返回一个 LayoutBuilder -> ClipRect -> Stack,其中 children 存放两个 child,background 和 title。
下面开始讲解 build 内部逻辑。
settings 为 FlexibleSpaceBarSettings
,用于控制 FlexibleSpaceBar 大小。它的唯一被调用的地方在 flexible_space_bar.dart 的 createSettings
中,这边就不放源码了。
deltaExtent 为 MyFlexibleSpaceBar 可伸缩的距离
threshold,threshold2 为动画触发点。
何为触发点,接着往下看 t 这个变量,注释写的很清楚当 SpaceBar 展开时 t = 0,反之 t = 1, 当我们上下滑动 SpaceBar 时,t 会从 1 到 0 波动。
t2 同 t,只不过只有在 SpaceBar 收缩的一半时,t2 才会产生变动。
在 t = threshold 时,图片由平铺变为一个圆 (直径 d 为80)。
在 t = threshold2,图片由圆变为一个小圆 (直径 d2 为45)。
height 为图片高度,imageWidth 为图片宽度。这里的 constraints 为 layoutbuilder 的约束。
final double deltaExtent = settings.maxExtent - settings.minExtent;
// 动画触发阈值
final threshold = 0.5;
final threshold2 = 0.75;
// 0.0 -> Expanded
// 1.0 -> Collapsed to toolbar
final double t =
(1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent)
.clamp(0.0, 1.0) as double;
// 0.0 -> Expanded
// 1.0 -> Half Collapsed to toolbar
final double t2 = ((settings.currentExtent - settings.minExtent) /
(deltaExtent * threshold))
.clamp(0.0, 1.0) as double;
final d = 80.0;
final d2 = 45.0;
final height = settings.maxExtent;
final imageWidth = constraints.maxWidth;
这里是 SpaceBar 中的第一个 Widget,也是本文最主要地方(图片缩放动画)。
Positioned 用于定位,因为最终返回的 Stack。
Align 将图片以屏幕左上对其。
UnconstrainedBox 取消父约束,以便后续设置图片大小。
Transform 对图片进行上下移动,形成一个视差动画,_getCollapsePadding 为移动的 Y 轴值。
AnimatedContainer 为核心代码,使用 width,height 来控制图片大小,使用 margin 来定位图片,使用 decoration 来控制图片形状。
children.add(Positioned(
top: 0,
left: 0,
right: 0,
height: height,
child: Align(
alignment: Alignment.topLeft,
child: UnconstrainedBox(
child: Transform(
transform: Matrix4.translationValues(
0, _getCollapsePadding(t, settings), 0),
child: AnimatedContainer(
width: t > threshold ? (t > threshold2 ? d2 : d) : imageWidth,
height: t > threshold ? (t > threshold2 ? d2 : d) : height,
curve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 200),
margin: t > threshold
? (t > threshold2
? EdgeInsets.only(
top: (settings.minExtent -
kToolbarHeight / 2 -
d2 / 2) +
_getCollapsePadding(1, settings).abs(),
left: 50)
: EdgeInsets.only(
top: deltaExtent / 2 - 16, left: 20))
: EdgeInsets.zero,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(
t > threshold ? d / 2 : 0,
)),
color: Colors.redAccent,
image: DecorationImage(
image: AssetImage('assets/acg.gy_29.webp'),
fit: BoxFit.cover,
),
),
),
),
),
),
));
针对上面的细节补充:
_getCollapsePadding(t, settings)
会根据 t (0~1) 返回 SpceBar 可移动距离的 1/4。
final double deltaExtent = settings.maxExtent - settings.minExtent;
return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
当 t 进行判断,0 ~ threshold 图片大小为 imageWidth 即屏幕宽度,threshold ~ threshold2 为 d (80),threshold2 ~ 1 为 d2 (45)。
height 同理。
t > threshold ? (t > threshold2 ? d2 : d) : imageWidth
这里将 AnimatedContainer 中的 margin 提出来以便于分析。
当 t 进行判断,0 ~ threshold 图片不做 margin ,threshold ~ threshold2 为 m2,threshold2 ~ 1 为 m1。
m1 中的 deltaExtent / 2 - 16 将图片定位到 SpaceBar 中间稍偏上(偏 16)。
m2 中的 top :(settings.minExtent - kToolbarHeight / 2 - d2 / 2)
为了将图片定位到 appbar 中间位置,_getCollapsePadding(1, settings).abs()
用于抵消 Transform 做的视差动画。
final m1 = EdgeInsets.only(top: deltaExtent / 2 - 16, left: 20);
final m2 = EdgeInsets.only(top: (settings.minExtent - kToolbarHeight / 2 - d2 / 2) + _getCollapsePadding(1, settings).abs(), left: 50);
...
margin: t > threshold? (t > threshold2 ? m2 : m1): EdgeInsets.zero,
...
title 不做分析。
另见:
Comments | NOTHING