flutter 常见组件的特殊用法 —— SliverAppBar

SliverAppBar 的滚动布局

特殊属性说明

primary: true

不同于 AppBar 通常有 Scaffold 包裹,其最大高度由父类约束。SliverAppBar 完全由自身决定。
当 primary 等于 true 时,其 topPadding 等于状态栏高度;若为 false,则 topPadding 等于 0,并且整体高度也会缩小(减去状态栏高度)。

collapsedHeight

最小高度由3部分组成:状态栏高度(47) + 传入的 collapsedHeight 高度 + 底栏高度(48)。

当 collapsedHeight 为空时,默认等于工具栏高度(56),除非当前配置为 固定、悬浮并且底栏不为空,则默认等于 0。

相关源码:

final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)
    ? (widget.collapsedHeight ?? 0.0) + bottomHeight + topPadding
    : (widget.collapsedHeight ?? widget.toolbarHeight) + bottomHeight + topPadding;

//...

@override
double get minExtent => collapsedHeight;

expandedHeight

最大高度,其值最小为 collapsedHeight,否则等于 状态栏高度 + 传入的 expandedHeight 高度。

当 expandedHeight 为空时,expandedHeight 默认等于 toolbarHeight 工具栏高度 + _bottomHeight 底栏高度。

相关源码:

  @override
  double get maxExtent => math.max(topPadding + (expandedHeight ?? (toolbarHeight ?? kToolbarHeight) + _bottomHeight), minExtent);

SliverAppBar 源码分析

@override
Widget build(BuildContext context) {
  assert(!widget.primary || debugCheckHasMediaQuery(context));
  final double bottomHeight = widget.bottom?.preferredSize.height ?? 0.0;
  // 若 primary 属性为 false,则 topPadding 等于 0,且整体高度缩小。
  // 注:跟 AppBar 不同,AppBar 外层由 Scaffold 包裹,其最大高度没有改变,只是整体向上移了。
  final double topPadding = widget.primary ? MediaQuery.of(context).padding.top : 0.0;
  // 最小高度 minExtent = 状态栏高度 + 传入的 collapsedHeight 高度 + 底栏高度
  final double collapsedHeight = (widget.pinned && widget.floating && widget.bottom != null)
      ? (widget.collapsedHeight ?? 0.0) + bottomHeight + topPadding
      : (widget.collapsedHeight ?? widget.toolbarHeight) + bottomHeight + topPadding;

  return MediaQuery.removePadding(
    context: context,
    removeBottom: true,
    // SliverPersistentHeader 用于实现固定、悬浮等效果
    child: SliverPersistentHeader(
      floating: widget.floating,
      pinned: widget.pinned,
      delegate: _SliverAppBarDelegate(
        vsync: this,
        leading: widget.leading,
        automaticallyImplyLeading: widget.automaticallyImplyLeading,
        title: widget.title,
        actions: widget.actions,
        flexibleSpace: widget.flexibleSpace,
        bottom: widget.bottom,
        elevation: widget.elevation,
        scrolledUnderElevation: widget.scrolledUnderElevation,
        shadowColor: widget.shadowColor,
        surfaceTintColor: widget.surfaceTintColor,
        forceElevated: widget.forceElevated,
        backgroundColor: widget.backgroundColor,
        foregroundColor: widget.foregroundColor,
        brightness: widget.brightness,
        iconTheme: widget.iconTheme,
        actionsIconTheme: widget.actionsIconTheme,
        textTheme: widget.textTheme,
        primary: widget.primary,
        centerTitle: widget.centerTitle,
        excludeHeaderSemantics: widget.excludeHeaderSemantics,
        titleSpacing: widget.titleSpacing,
        expandedHeight: widget.expandedHeight,
        collapsedHeight: collapsedHeight,
        topPadding: topPadding,
        floating: widget.floating,
        pinned: widget.pinned,
        shape: widget.shape,
        snapConfiguration: _snapConfiguration,
        stretchConfiguration: _stretchConfiguration,
        showOnScreenConfiguration: _showOnScreenConfiguration,
        toolbarHeight: widget.toolbarHeight,
        leadingWidth: widget.leadingWidth,
        backwardsCompatibility: widget.backwardsCompatibility,
        toolbarTextStyle: widget.toolbarTextStyle,
        titleTextStyle: widget.titleTextStyle,
        systemOverlayStyle: widget.systemOverlayStyle,
      ),
    ),
  );
}

_SliverAppBarDelegate 源码

@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
  // shrinkOffset = math.min(scrollOffset, maxExtent)
  // (注:scrollOffset 属性表示滚动滑出Viewport边界的距离,未到达顶部之前都是 0。)
  // 通常来说,完全展开时为 0;完全缩小时等于 kToolBarHeight,亦即导航栏向上滑出的距离。
  final double visibleMainHeight = maxExtent - shrinkOffset - topPadding;
  final double extraToolbarHeight = math.max(minExtent - _bottomHeight - topPadding - (toolbarHeight ?? kToolbarHeight), 0.0);
  // visibleToolbarHeight 工具栏显示出来的高度,当工具栏恰好隐藏的时候为 0. 可
  // 以简单理解为 currentExtent - bottomHeight
  final double visibleToolbarHeight = visibleMainHeight - _bottomHeight - extraToolbarHeight;

  final bool isScrolledUnder = overlapsContent || (pinned && shrinkOffset > maxExtent - minExtent);
  // isPinnedWithOpacityFade 满足条件:导航处于固定悬浮状态,即之前提到的工具栏向上滚动时会隐藏
  final bool isPinnedWithOpacityFade = pinned && floating && bottom != null && extraToolbarHeight == 0.0;
  // 文字的透明度:
  // 注意从隐藏到显示出来的过程是由完全透明(0)到不透明(1)(具体效果是白色由浅到深)。
  // 其余情况一律不透明。
  final double toolbarOpacity = !pinned || isPinnedWithOpacityFade
      ? (visibleToolbarHeight / (toolbarHeight ?? kToolbarHeight)).clamp(0.0, 1.0)
      : 1.0;

  final Widget appBar = FlexibleSpaceBar.createSettings(
    minExtent: minExtent,
    maxExtent: maxExtent,
    //当前滚动的高度(最大是 expandedHeight,即 SliverAppBar 完全展开时)
    currentExtent: math.max(minExtent, maxExtent - shrinkOffset),
    toolbarOpacity: toolbarOpacity,
    isScrolledUnder: isScrolledUnder,
    child: AppBar(
      leading: leading,
      automaticallyImplyLeading: automaticallyImplyLeading,
      title: title,
      actions: actions,
      flexibleSpace: (title == null && flexibleSpace != null && !excludeHeaderSemantics)
          ? Semantics(
        header: true,
        child: flexibleSpace,
      )
          : flexibleSpace,
      bottom: bottom,
      elevation: forceElevated || isScrolledUnder ? elevation : 0.0,
      scrolledUnderElevation: scrolledUnderElevation,
      shadowColor: shadowColor,
      surfaceTintColor: surfaceTintColor,
      backgroundColor: backgroundColor,
      foregroundColor: foregroundColor,
      brightness: brightness,
      iconTheme: iconTheme,
      actionsIconTheme: actionsIconTheme,
      textTheme: textTheme,
      primary: primary,
      centerTitle: centerTitle,
      excludeHeaderSemantics: excludeHeaderSemantics,
      titleSpacing: titleSpacing,
      shape: shape,
      toolbarOpacity: toolbarOpacity,
      bottomOpacity: pinned ? 1.0 : ((visibleMainHeight / _bottomHeight).clamp(0.0, 1.0)),
      toolbarHeight: toolbarHeight,
      leadingWidth: leadingWidth,
      backwardsCompatibility: backwardsCompatibility,
      toolbarTextStyle: toolbarTextStyle,
      titleTextStyle: titleTextStyle,
      systemOverlayStyle: systemOverlayStyle,
    ),
  );
  // 返回值是 AppBar
  return appBar;
}

总结:SliverAppBar 的本质是 SliverPersistentHeader,其 delegate 是 _SliverAppBarDelegate,它的 build 方法返回值为 AppBar。


SliverAppBar 效果实现 —— 工具栏自动隐藏、底栏固定

1、当 floating 等于 true 时,并且 pinned 也等于 true,且 bottom 不为空时,并且 collapsedHeight 为空,则向上滚动时会隐藏工具栏,向下滚动会显示工具栏。

说明:SliverAppBar 的滚动分两部分,一是内部滚动,二是外部滚动。
① 内部滚动主要发生在 collapsedHeight 与 expandedHeight 高度不一致时。比如当前这个场景,collapsedHeight = topPadding + 0 + bottomHeight,而 expandedHeight 等于 topPadding + toolbarHeight + bottomHeight,这样向上滚动时就会隐藏 工具栏。
② 外部滚动发生在当 pinned 属性为 false 时,当前面说的内部滚动结束后,整个 appBar 会随着 body 的滚动产生外部滚动,不断往上滚动后会隐藏整个导航栏。

另注:工具栏在向下滚动时逐渐显示工具栏的过程中,文字透明度也由透明转向完全不透明(白色由浅到深)。

效果:

相关代码:

class SliverAppBarFloatPage extends StatefulWidget {
  const SliverAppBarFloatPage({Key? key}) : super(key: key);

  @override
  State<SliverAppBarFloatPage> createState() => _SliverAppBarFloatPageState();
}

class _SliverAppBarFloatPageState extends State<SliverAppBarFloatPage> {
  @override
  Widget build(BuildContext context) {
    var mediaQuery = MediaQuery.of(context);
    return Scaffold(
      backgroundColor: Colors.blueGrey,
      // 创建一个公共的 Scrollable 和 Viewport
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            backgroundColor: Colors.transparent,
            title: Text("SliverAppBar"),
            pinned: true,
            floating: true,
            bottom: PreferredSize(
              preferredSize: Size.fromHeight(kTextTabBarHeight),
              child: Container(height: kTextTabBarHeight, color: Colors.yellow.withOpacity(0.5),),
            ),
          ),
          SliverList(
            delegate: SliverChildListDelegate([
              Container(
                color: Colors.red,
                height: 1100,
              )
            ]),
          ),
        ],
      ),
    );
  }
}

2、floating 等于 true 还有一个作用,就是当 snap 等于 true 时,在滚动时会触发一个自动完成工具栏滚动/隐藏的动画效果。具体原理有时间再分析。


示例2:

避免下方列表的滚动区间随外部滚动偏移到最上方,使用 NestedScrollView 进行联结

class NestedTabViewPage extends StatelessWidget {
  const NestedTabViewPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final tabs = <String>['猜你喜欢', '今日特价', '发现更多'];
    return DefaultTabController(
      length: tabs.length, // This is the number of tabs.
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverOverlapAbsorber(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  title: const Text('商城'),
                  forceElevated: innerBoxIsScrolled,
                  floating: true,
                  snap: true,
                  pinned: true,
                  bottom: TabBar(
                    tabs: tabs.map((String name) => Tab(text: name)).toList(),
                  ),
                ),
              )
            ];
          },
          body: TabBarView(
            children: tabs.map((String name) {
              return Builder(
                builder: (BuildContext context) {
                  return CustomScrollView(
                    key: PageStorageKey<String>(name),
                    slivers: <Widget>[
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverPadding(
                        padding: const EdgeInsets.all(8.0),
                        sliver: SliverFixedExtentList(
                          itemExtent: 50,
                          delegate: SliverChildBuilderDelegate(
                            (context, index) {
                              return ListTile(title: Text('$index'));
                            },
                            childCount: 25,
                          ),
                        ),
                      ),
                    ],
                  );
                },
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}

SliverAppBar 效果实现 —— 背景层 FlexibleSpaceBar 滚动动画

特殊属性说明

collapseMode

导航栏正常收缩时背景的动画效果

enum CollapseMode {
  /// 默认效果,视差,收缩时背景缓慢地向上滚动
  parallax,

  /// 直接滚动
  pin,

  /// 背景固定不动
  none,
}

stretchModes

导航栏向下正常滚动(内部滚动)结束后,也会发生外部滚动,如果 SliverAppBar.stretch 等于 true,那么这个外部滚动是整个页面(包括导航栏)一起滚动(类似 于 extendBodyBehindAppBar 增加 body 内部的 sliverPadding)。并且 ScrollViewport 组件的 physics 效果为 BouncingScrollPhysics,这会进一步拉伸页面。此时会触发 FlexibleSpaceBar 容器放大导致的背景图片变化的一个动画效果。

enum StretchMode {
  /// 默认效果,放大图片(如果背景宽度已经铺满了,则不会再放大)
  zoomBackground,

  /// 模糊背景
  blurBackground,

  /// 淡化 title
  fadeTitle,
}

效果:

相关代码:

class SliverAppBarStretchPage extends StatefulWidget {
  const SliverAppBarStretchPage({Key? key}) : super(key: key);

  @override
  State<SliverAppBarStretchPage> createState() => _SliverAppBarStretchPageState();
}

class _SliverAppBarStretchPageState extends State<SliverAppBarStretchPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.blueGrey,
      // 创建一个公共的 Scrollable 和 Viewport
      body: CustomScrollView(
        // 弹性效果(在滚动到尽头时仍可继续滚动)
        physics: BouncingScrollPhysics(),
        slivers: [
          SliverAppBar(
            backgroundColor: Colors.transparent,
            title: Text("SliverAppBar"),
            // 让 FlexibleSpaceBar 与外部组件同步滚动(在内部滚动结束后)
            stretch: true,
            flexibleSpace: FlexibleSpaceBar(
                collapseMode: CollapseMode.pin,
                stretchModes: const <StretchMode>[StretchMode.zoomBackground],
                background: Image.network(
                  "https://r11.realme.net/CN/thread/1555770224727732224.jpg",
                  //默认是 fit: BoxFit.contain,在容器宽高比背景图片的宽高都大时
                  //(比如原始图片比较小),不再放大。可以设置为其它效果,让其放大
                  fit: BoxFit.fitWidth,
                )),
          ),
          SliverList(
            delegate: SliverChildListDelegate([
              Container(
                color: Colors.red,
                height: 1100,
              )
            ]),
          ),
        ],
      ),
    );
  }
}

FlexibleSpaceBar 源码分析

flexibleSpace 不为空的 AppBar 是一个 Stack 布局,appBar 本身在上层, flexibleSpace 在底层。同时,SliverAppBar.backgroundColor 位于 Stack 的父组件,实际上是处于最底层。

注:可参考 https://www.cnblogs.com/lemos/p/16581874.html

简单看下 FlexibleSpaceBar 的源码:

class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {

  //标题是否居中(ios/macos 居中)
  bool _getEffectiveCenterTitle(ThemeData theme) {
    if (widget.centerTitle != null)
      return widget.centerTitle!;
    assert(theme.platform != null);
    switch (theme.platform) {
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return false;
      case TargetPlatform.iOS:
      case TargetPlatform.macOS:
        return true;
    }
  }

  //标题布局
  Alignment _getTitleAlignment(bool effectiveCenterTitle) {
    if (effectiveCenterTitle)
      //底部居中
      return Alignment.bottomCenter;
    final TextDirection textDirection = Directionality.of(context);
    assert(textDirection != null);
    switch (textDirection) {
      case TextDirection.rtl:
        //底部居右
        return Alignment.bottomRight;
      case TextDirection.ltr:
        //底部居左
        return Alignment.bottomLeft;
    }
  }

  //背景缩放模式
  double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) {
    switch (widget.collapseMode) {
      case CollapseMode.pin:
        return -(settings.maxExtent - settings.currentExtent);
      case CollapseMode.none:
        return 0.0;
      case CollapseMode.parallax:
        final double deltaExtent = settings.maxExtent - settings.minExtent;
        return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
    }
  }

  @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;

        // 0.0 -> Expanded
        // 1.0 -> Collapsed to toolbar
        // 向下滚动 t 逐渐变成 0
        final double t = (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);

        // 一、background 背景组件部分
        if (widget.background != null) {
          final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaExtent);
          const double fadeEnd = 1.0;
          assert(fadeStart <= fadeEnd);
          // If the min and max extent are the same, the app bar cannot collapse
          // and the content should be visible, so opacity = 1.
          //背景透明:在最大高度与最小高度不相等的情况下,随着向下滚动, t 逐渐变为 0,同时 opacity 由 透明转向不透明。
          final double opacity = settings.maxExtent == settings.minExtent
              ? 1.0
              : 1.0 - Interval(fadeStart, fadeEnd).transform(t);
          double height = settings.maxExtent;

          // StretchMode.zoomBackground
          //背景缩放效果:zoomBackground
          if (widget.stretchModes.contains(StretchMode.zoomBackground) &&
              constraints.maxHeight > height) {
            height = constraints.maxHeight;
          }

          //① 添加背景组件
          children.add(Positioned(
            // 背景滚动效果:collapseMode:pin, none, parallax
            top: _getCollapsePadding(t, settings),
            left: 0.0,
            right: 0.0,
            height: height,
            child: Opacity(
              // IOS is relying on this semantics node to correctly traverse
              // through the app bar when it is collapsed.
              alwaysIncludeSemantics: true,
              opacity: opacity,
              child: widget.background,
            ),
          ));

          // StretchMode.blurBackground
          if (widget.stretchModes.contains(StretchMode.blurBackground) &&
              constraints.maxHeight > settings.maxExtent) {
            final double blurAmount = (constraints.maxHeight - settings.maxExtent) / 10;
            //② 添加模糊效果组件
            children.add(Positioned.fill(
              child: BackdropFilter(
                filter: ui.ImageFilter.blur(
                  sigmaX: blurAmount,
                  sigmaY: blurAmount,
                ),
                child: Container(
                  color: Colors.transparent,
                ),
              ),
            ));
          }
        }

        // 二、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));
            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 bool effectiveCenterTitle = _getEffectiveCenterTitle(theme);
            final EdgeInsetsGeometry padding = widget.titlePadding ??
                EdgeInsetsDirectional.only(
                  start: effectiveCenterTitle ? 0.0 : 72.0,
                  bottom: 16.0,
                );
            //标题缩放
            final double scaleValue = Tween<double>(begin: widget.expandedTitleScale, end: 1.0).transform(t);
            final Matrix4 scaleTransform = Matrix4.identity()
              ..scale(scaleValue, scaleValue, 1.0);
            final Alignment titleAlignment = _getTitleAlignment(effectiveCenterTitle);
            //③ 添加标题容器
            children.add(Container(
              padding: padding,
              child: Transform(
                alignment: titleAlignment,
                transform: scaleTransform,
                child: Align(
                  alignment: titleAlignment,
                  child: DefaultTextStyle(
                    style: titleStyle,
                    child: LayoutBuilder(
                      builder: (BuildContext context, BoxConstraints constraints) {
                        return Container(
                          width: constraints.maxWidth / scaleValue,
                          alignment: titleAlignment,
                          child: title,
                        );
                      },
                    ),
                  ),
                ),
              ),
            ));
          }
        }

        // 返回值也是一个 Stack 布局
        return ClipRect(child: Stack(children: children));
      },
    );
  }
}

增加 overlay 遮罩

有时间再写

233

posted on 2022-08-14 13:56  Lemo_wd  阅读(3238)  评论(0编辑  收藏  举报

导航