先上效果图

仿 Telegram AppBar 示例图
示例~

这是一个经典的 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 不做分析。

另见: