如何在 SliverAppBar 中为项目的位置设置动画以在关闭时围绕标题移动它们

Posted

技术标签:

【中文标题】如何在 SliverAppBar 中为项目的位置设置动画以在关闭时围绕标题移动它们【英文标题】:How to animate the position of the items in a SliverAppBar to move them around the title when closed 【发布时间】:2021-06-23 18:43:45 【问题描述】:

我对 Appbar 有这些要求,但我找不到解决这些问题的方法。

拉伸时,AppBar 必须显示两个图像一个在另一个之上,并且标题必须隐藏。 关闭时,AppBar 必须显示标题,并且两个图像在滚动时必须按比例缩小并移动到标题的两侧。滚动时标题可见。

我创建了几个模型来帮助获得所需的结果。

这是拉伸时的 Appbar:

这是关闭时的应用栏:

【问题讨论】:

【参考方案1】:

您可以通过扩展SliverPersistentHeaderDelegate 创建自己的SliverAppBar

平移、缩放和不透明度更改将在 build(...) 方法中完成,因为这将在范围更改期间调用(通过滚动)minExtent <-> maxExtent

这是一个示例代码。

import 'dart:math';

import 'package:flutter/material.dart';

void main() 
  runApp(MyApp());


class MyApp extends StatelessWidget 
  @override
  Widget build(BuildContext context) 
    return MaterialApp(
      theme: ThemeData(
        primaryColor: Colors.blue,
      ),
      home: HomePage(),
    );
  


class HomePage extends StatelessWidget 
  @override
  Widget build(BuildContext context) 
    return Scaffold(
      body: CustomScrollView(
        slivers: <Widget>[
          SliverPersistentHeader(
            delegate: MySliverAppBar(
              title: 'Sample',
              minWidth: 50,
              minHeight: 25,
              leftMaxWidth: 200,
              leftMaxHeight: 100,
              rightMaxWidth: 100,
              rightMaxHeight: 50,
              shrinkedTopPos: 10,
            ),
            pinned: true,
          ),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (_, int i) => Container(
                height: 50,
                color: Color.fromARGB(
                  255,
                  Random().nextInt(255),
                  Random().nextInt(255),
                  Random().nextInt(255),
                ),
              ),
              childCount: 50,
            ),
          ),
        ],
      ),
    );
  


class MySliverAppBar extends SliverPersistentHeaderDelegate 
  MySliverAppBar(
    required this.title,
    required this.minWidth,
    required this.minHeight,
    required this.leftMaxWidth,
    required this.leftMaxHeight,
    required this.rightMaxWidth,
    required this.rightMaxHeight,
    this.titleStyle = const TextStyle(fontSize: 26),
    this.shrinkedTopPos = 0,
  );

  final String title;
  final TextStyle titleStyle;
  final double minWidth;
  final double minHeight;
  final double leftMaxWidth;
  final double leftMaxHeight;
  final double rightMaxWidth;
  final double rightMaxHeight;

  final double shrinkedTopPos;

  final GlobalKey _titleKey = GlobalKey();

  double? _topPadding;
  double? _centerX;
  Size? _titleSize;

  double get _shrinkedTopPos => _topPadding! + shrinkedTopPos;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) 
    if (_topPadding == null) 
      _topPadding = MediaQuery.of(context).padding.top;
    
    if (_centerX == null) 
      _centerX = MediaQuery.of(context).size.width / 2;
    
    if (_titleSize == null) 
      _titleSize = _calculateTitleSize(title, titleStyle);
    

    double percent = shrinkOffset / (maxExtent - minExtent);
    percent = percent > 1 ? 1 : percent;

    return Container(
      color: Colors.red,
      child: Stack(
        children: <Widget>[
          _buildTitle(shrinkOffset),
          _buildLeftImage(percent),
          _buildRightImage(percent),
        ],
      ),
    );
  

  Size _calculateTitleSize(String text, TextStyle style) 
    final TextPainter textPainter = TextPainter(
        text: TextSpan(text: text, style: style),
        maxLines: 1,
        textDirection: TextDirection.ltr)
      ..layout(minWidth: 0, maxWidth: double.infinity);
    return textPainter.size;
  

  Widget _buildTitle(double shrinkOffset) => Align(
        alignment: Alignment.topCenter,
        child: Padding(
          padding: EdgeInsets.only(top: _topPadding!),
          child: Opacity(
            opacity: shrinkOffset / maxExtent,
            child: Text(title, key: _titleKey, style: titleStyle),
          ),
        ),
      );

  double getScaledWidth(double width, double percent) =>
      width - ((width - minWidth) * percent);

  double getScaledHeight(double height, double percent) =>
      height - ((height - minHeight) * percent);

  /// 20 is the padding between the image and the title
  double get shrinkedHorizontalPos =>
      (_centerX! - (_titleSize!.width / 2)) - minWidth - 20;

  Widget _buildLeftImage(double percent) 
    final double topMargin = minExtent;
    final double rangeLeft =
        (_centerX! - (leftMaxWidth / 2)) - shrinkedHorizontalPos;
    final double rangeTop = topMargin - _shrinkedTopPos;

    final double top = topMargin - (rangeTop * percent);
    final double left =
        (_centerX! - (leftMaxWidth / 2)) - (rangeLeft * percent);

    return Positioned(
      left: left,
      top: top,
      child: Container(
        width: getScaledWidth(leftMaxWidth, percent),
        height: getScaledHeight(leftMaxHeight, percent),
        color: Colors.black,
      ),
    );
  

  Widget _buildRightImage(double percent) 
    final double topMargin = minExtent + (rightMaxHeight / 2);
    final double rangeRight =
        (_centerX! - (rightMaxWidth / 2)) - shrinkedHorizontalPos;
    final double rangeTop = topMargin - _shrinkedTopPos;

    final double top = topMargin - (rangeTop * percent);
    final double right =
        (_centerX! - (rightMaxWidth / 2)) - (rangeRight * percent);

    return Positioned(
      right: right,
      top: top,
      child: Container(
        width: getScaledWidth(rightMaxWidth, percent),
        height: getScaledHeight(rightMaxHeight, percent),
        color: Colors.white,
      ),
    );
  

  @override
  double get maxExtent => 300;

  @override
  double get minExtent => _topPadding! + 50;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
      false;

【讨论】:

好一个!它已经足够接近我想要的了,但是我想要盒子,当 appbar 被拉伸时,就在靠近标题的地方,而不是在角落里。无论标题有多长。 对不起,我不太明白。 “当 appbar 被拉伸时”是指在标题可见期间? “刚刚接近标题”的意思是shrinkedLeftPosshrinkedRightPos应该计算? 对不起,我的错,我在手机上写得很快。我的意思是“缩小”,而不是“拉伸”。我想说的是,当 AppBar 在 minExtent 中时,最终布局必须是(伪代码)行(对齐:中​​心,孩子:[box,little padding,title,little padding,box])。但在您的解决方案中,框将被放置在应用栏的角落。 OK2.明白了。我稍后会修改答案。 (晚餐先:D) @Mou 答案已更新。请参考shrinkedHorizontalPos【参考方案2】:

它的公式有点乱,但这里是关于动画的所有计算的方法:

UPD:添加到代码变量中以使图像在扩展时产生 Y 轴偏移。

完整代码重现:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget 
  @override
  Widget build(BuildContext context) 
    return MaterialApp(
      title: 'Material App',
      home: Body(),
    );
  


class Body extends StatefulWidget 
  const Body(
    Key key,
  ) : super(key: key);

  @override
  _BodyState createState() => _BodyState();


class _BodyState extends State<Body> 
  double _collapsedHeight = 60;
  double _expandedHeight = 200;
  double
      extentRatio; // Value to control SliverAppBar widget sizes, based on BoxConstraints and
  double minH1 = 40; // Minimum height of the first image.
  double minW1 = 30; // Minimum width of the first image.
  double minH2 = 20; // Minimum height of second image.
  double minW2 = 25; // Minimum width of second image.
  double maxH1 = 60; // Maximum height of the first image.
  double maxW1 = 60; // Maximum width of the first image.
  double maxH2 = 40; // Maximum height of second image.
  double maxW2 = 50; // Maximum width of second image.
  double textWidth = 70; // Width of a given title text.
  double extYAxisOff = 10.0; // Offset on Y axis for both images when sliver is extended.
  @override
  Widget build(BuildContext context) 
    return Scaffold(
      body: SafeArea(
        child: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) 
            return <Widget>[
              SliverAppBar(
                  collapsedHeight: _collapsedHeight,
                  expandedHeight: _expandedHeight,
                  floating: true,
                  pinned: true,
                  flexibleSpace: LayoutBuilder(
                    builder:
                        (BuildContext context, BoxConstraints constraints) 
                      extentRatio =
                          (constraints.biggest.height - _collapsedHeight) /
                              (_expandedHeight - _collapsedHeight);
                      double xAxisOffset1 = (-(minW1 - minW2) -
                              textWidth +
                              (textWidth + maxW1) * extentRatio) /
                          2;
                      double xAxisOffset2 = (-(minW1 - minW2) +
                              textWidth +
                              (-textWidth - maxW2) * extentRatio) /
                          2;
                      double yAxisOffset2 = (-(minH1 - minH2) -
                                  (maxH1 - maxH2 - (minH1 - minH2)) *
                                      extentRatio) /
                              2 -
                          extYAxisOff * extentRatio;
                      double yAxisOffset1 = -extYAxisOff * extentRatio;
                      print(extYAxisOff);
                      // debugPrint('constraints=' + constraints.toString());
                      // debugPrint('Scale ratio is $extentRatio');
                      return FlexibleSpaceBar(
                        titlePadding: EdgeInsets.all(0),
                        // centerTitle: true,
                        title: Stack(
                          children: [
                            Align(
                              alignment: Alignment.topCenter,
                              child: AnimatedOpacity(
                                duration: Duration(milliseconds: 300),
                                opacity: extentRatio < 1 ? 1 : 0,
                                child: Padding(
                                  padding: const EdgeInsets.only(top: 30.0),
                                  child: Container(
                                    color: Colors.indigo,
                                    width: textWidth,
                                    alignment: Alignment.center,
                                    height: 20,
                                    child: Text(
                                      "TITLE TEXT",
                                      style: TextStyle(
                                        color: Colors.white,
                                        fontSize: 12.0,
                                      ),
                                    ),
                                  ),
                                ),
                              ),
                            ),
                            Align(
                              alignment: Alignment.bottomCenter,
                              child: Row(
                                crossAxisAlignment: CrossAxisAlignment.end,
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  Container(
                                    transform: Matrix4(
                                        1,0,0,0,
                                        0,1,0,0,
                                        0,0,1,0,
                                        xAxisOffset1,yAxisOffset1,0,1),
                                    width:
                                        minW1 + (maxW1 - minW1) * extentRatio,
                                    height:
                                        minH1 + (maxH1 - minH1) * extentRatio,
                                    color: Colors.red,
                                  ),
                                  Container(
                                    transform: Matrix4(
                                        1,0,0,0,
                                        0,1,0,0,
                                        0,0,1,0,
                                        xAxisOffset2,yAxisOffset2,0,1),
                                  
                                    width:
                                        minW2 + (maxW2 - minW2) * extentRatio,
                                    height:
                                        minH2 + (maxH2 - minH2) * extentRatio,
                                    color: Colors.purple,
                                  ),
                                ],
                              ),
                            ),
                          ],
                        ),
                      );
                    ,
                  )),
            ];
          ,
          body: Center(
            child: Text("Sample Text"),
          ),
        ),
      ),
    );
  


【讨论】:

看起来不错!但是标题可以有可变长度,并且不能使用 AnimatedContainer,因为元素必须由滚动本身插入,而不是由“外部”动画插入。 @Mou 但元素是由滚动插入的,随时计算 是的,你是对的,但我们可以避免使用 AnimatedContainer,因为插值已经由滚动生成。另外,你还有一个固定的标题长度:) 是的,你对容器的看法是正确的,修复了它,关于标题的保留原样。可能有人会像那样需要它。这个要求不在问题之列。

以上是关于如何在 SliverAppBar 中为项目的位置设置动画以在关闭时围绕标题移动它们的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 NestedScrollView 滚动以在 SliverAppBar 上使用堆栈圆形头像?

如何在您的Flutter应用程序中添加SliverAppBar

如何修改 Sliverappbar 以便在向上滚动时可以看到带有 FlexibleSpaceBar 的背景图像?

如何将卡片小部件放在 SliverAppBar 中?

如何创建具有上部固定和下部浮动元素的 SliverAppBar?

如何在 SliverAppBar 上制作一个动作按钮(不是真的,它只是一个文本框)