Flutter Stack 控件实现探探卡片布局

2020-06-2107:47:38APP与小程序开发Comments1,866 views字数 12822阅读模式

初识 Stack

Stack 是一个有多子项的控件,它会将自己的子项相对于自身边缘进行定位,后面的子项会覆盖前面的子项。通常用来实现将一个控件覆盖于另一个控件之上的布局,比如在一张图片上显示一些文字。子项的默认位置在 Stack 左上角,也可以用 Align 或者 Positioned 控件分别进行定位。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

Flutter Stack 控件实现探探卡片布局文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

Stack(
  children: <Widget>[
    Container(
      width: 100,
      height: 100,
      color: Colors.red,
    ),
    Container(
      width: 90,
      height: 90,
      color: Colors.green,
    ),
    Container(
      width: 80,
      height: 80,
      color: Colors.blue,
    ),
  ],
)
复制代码

Stack (Flutter Widget of the Week)文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

布局思路

要使用 Stack 实现这个卡片布局的大致思路如下文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

  1. 首先需要前,中,后三个子控件,使用 Align 控件定位在容器中。
  2. 需要一个手势监听器 GestureDetector 监听手指滑动。
  3. 监听手指在屏幕上滑动同时更新最前面卡片的位置。
  4. 判断移动的横轴距离进行卡片位置变换动画或者卡片回弹动画。
  5. 如果运行了卡片位置变换动画在动画结束后更新卡片的索引值。

卡片布局

  1. 创建 Stack 容器以及前,中,后三个子控件
class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  //  前面的卡片,使用 Align 定位
  Widget _frontCard() {
    return Align(
      child: Container(
        color: Colors.blue,
      ),
    );
  }

  // 中间的卡片,使用 Align 定位
  Widget _middleCard() {
    return Align(
      child: Container(
        color: Colors.red,
      ),
    );
  }

  // 后面的卡片,使用 Align 定位
  Widget _backCard() {
    return Align(
      child: Container(
        color: Colors.green,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TCards demo',
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: SizedBox(
            width: 300,
            height: 400,
            child: Stack(
              children: [
                // 后面的子项会显示在上面,所以前面的卡片放在最后
                _backCard(),
                _middleCard(),
                _frontCard(),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
复制代码

Flutter Stack 控件实现探探卡片布局文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

  1. 对子控件分别定位并设置其尺寸

定位需要设置 Align 控件的 alignment 属性,传入一个 Alignment(x, y) 进行设置。设置尺寸需要使用 LayoutBuilder 获取当前父容器的尺寸,然后根据容器尺寸进行计算。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

class _MyAppState extends State<MyApp> {
  //  前面的卡片,使用 Align 定位
  Widget _frontCard(BoxConstraints constraints) {
    return Align(
      alignment: Alignment(0.0, -0.5),
      // 使用 SizedBox 确定卡片尺寸
      child: SizedBox.fromSize(
        // 计算卡片尺寸,相对于父容器
        size: Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9),
        child: Container(
          color: Colors.blue,
        ),
      ),
    );
  }

  // 中间的卡片,使用 Align 定位
  Widget _middleCard(BoxConstraints constraints) {
    return Align(
      alignment: Alignment(0.0, 0.0),
      child: SizedBox.fromSize(
        // 计算卡片尺寸,相对于父容器
        size: Size(constraints.maxWidth * 0.85, constraints.maxHeight * 0.9),
        child: Container(
          color: Colors.red,
        ),
      ),
    );
  }

  // 后面的卡片,使用 Align 定位
  Widget _backCard(BoxConstraints constraints) {
    return Align(
      alignment: Alignment(0.0, 0.5),
      child: SizedBox.fromSize(
        // 计算卡片尺寸,相对于父容器
        size: Size(constraints.maxWidth * 0.8, constraints.maxHeight * .9),
        child: Container(
          color: Colors.green,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TCards demo',
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: SizedBox(
            width: 300,
            height: 400,
            child: LayoutBuilder(
              builder: (context, constraints) {
                // 使用 LayoutBuilder 获取容器的尺寸,传个子项计算卡片尺寸
                return Stack(
                  children: [
                    // 后面的子项会显示在上面,所以前面的卡片放在最后
                    _backCard(constraints),
                    _middleCard(constraints),
                    _frontCard(constraints),
                  ],
                );
              },
            ),
          ),
        ),
      ),
    );
  }
}

复制代码

Flutter Stack 控件实现探探卡片布局文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

  1. 更新最前面卡片位置

Stack 容器添加一个 GestureDetector,手指在屏幕上移动时更新最前面卡片的位置。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

class _MyAppState extends State<MyApp> {
  // 保存最前面卡片的定位
  Alignment _frontCardAlignment = Alignment(0.0, -0.5);
  // 保存最前面卡片的旋转角度
  double _frontCardRotation = 0.0;

  //  前面的卡片,使用 Align 定位
  Widget _frontCard(BoxConstraints constraints) {
    return Align(
      alignment: _frontCardAlignment,
      // 使用 Transform.rotate 旋转卡片
      child: Transform.rotate(
        angle: (pi / 180.0) * _frontCardRotation,
        // 使用 SizedBox 确定卡片尺寸
        child: SizedBox.fromSize(
          size: Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9),
          child: Container(
            color: Colors.blue,
          ),
        ),
      ),
    );
  }

  // 省略......

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TCards demo',
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: SizedBox(
            width: 300,
            height: 400,
            child: LayoutBuilder(
              builder: (context, constraints) {
                // 使用 LayoutBuilder 获取容器的尺寸,传个子项计算卡片尺寸
                Size size = MediaQuery.of(context).size;
                double speed = 10.0;

                return Stack(
                  children: [
                    // 后面的子项会显示在上面,所以前面的卡片放在最后
                    _backCard(constraints),
                    _middleCard(constraints),
                    _frontCard(constraints),
                    // 使用一个占满父元素的 GestureDetector 监听手指移动
                    SizedBox.expand(
                      child: GestureDetector(
                        onPanDown: (DragDownDetails details) {},
                        onPanUpdate: (DragUpdateDetails details) {
                          // 手指移动就更新最前面卡片的 alignment 属性
                          _frontCardAlignment += Alignment(
                            details.delta.dx / (size.width / 2) * speed,
                            details.delta.dy / (size.height / 2) * speed,
                          );
                          // 设置最前面卡片的旋转角度
                          _frontCardRotation = _frontCardAlignment.x;
                          // setState 更新界面
                          setState(() {});
                        },
                        onPanEnd: (DragEndDetails details) {},
                      ),
                    ),
                  ],
                );
              },
            ),
          ),
        ),
      ),
    );
  }
}
复制代码

Flutter Stack 控件实现探探卡片布局文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

卡片动画

这个布局有三种动画,最前面卡片移开的动画;后面两张卡片位置和尺寸变化的动画;最前面卡片回到原位的动画。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

  1. 判断卡片横轴移动距离

在手指离开屏幕时判断卡片横轴的移动距离,如果最前面的卡片横轴移动距离超过限制就运行换位动画,否则运行回弹动画。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

// 改变位置的动画
void _runChangeOrderAnimation() {}

// 卡片回弹的动画
void _runReboundAnimation(Offset pixelsPerSecond, Size size) {}

// 省略...

// 卡片横轴距离限制
final double limit = 10.0;

SizedBox.expand(
  child: GestureDetector(
    // 省略...
    onPanEnd: (DragEndDetails details) {
      // 如果最前面的卡片横轴移动距离超过限制就运行换位动画,否则运行回弹动画
      if (_frontCardAlignment.x > limit ||
          _frontCardAlignment.x < -limit) {
        _runChangeOrderAnimation();
      } else {
        _runReboundAnimation(
          details.velocity.pixelsPerSecond,
          size,
        );
      }
    },
  ),
),
复制代码
  1. 卡片回弹动画

首先实现卡片回弹的动画,使用 AnimationController 控制动画,在 initState 初始化动画控制器。创建一个 AlignmentTween 设置动画运动值,起始值是卡片当前位置,最终值是卡片的默认位置。然后将一个弹簧模拟 SpringSimulation 传递给动画控制器,让动画模拟运行。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

class _MyAppState extends State<MyApp> with TickerProviderStateMixin {
  // 省略...
  // 卡片回弹动画
  Animation<Alignment> _reboundAnimation;
  // 卡片回弹动画控制器
  AnimationController _reboundController;

  // 省略...

  // 卡片回弹的动画
  void _runReboundAnimation(Offset pixelsPerSecond, Size size) {
    // 创建动画值
    _reboundAnimation = _reboundController.drive(
      AlignmentTween(
        // 起始值是卡片当前位置,最终值是卡片的默认位置
        begin: _frontCardAlignment,
        end: Alignment(0.0, -0.5),
      ),
    );
    // 计算卡片运动速度
    final double unitsPerSecondX = pixelsPerSecond.dx / size.width;
    final double unitsPerSecondY = pixelsPerSecond.dy / size.height;
    final unitsPerSecond = Offset(unitsPerSecondX, unitsPerSecondY);
    final unitVelocity = unitsPerSecond.distance;
    // 创建弹簧模拟的定义
    const spring = SpringDescription(mass: 30, stiffness: 1, damping: 1);
    // 创建弹簧模拟
    final simulation = SpringSimulation(spring, 0, 1, -unitVelocity);
    // 根据给定的模拟运行动画
    _reboundController.animateWith(simulation);
    // 重置旋转值
    _frontCardRotation = 0.0;
    setState(() {});
  }

  @override
  void initState() {
    super.initState();
    // 初始化回弹的动画控制器
    _reboundController = AnimationController(vsync: this)
      ..addListener(() {
        setState(() {
          // 动画运行时更新最前面卡片的 alignment 属性
          _frontCardAlignment = _reboundAnimation.value;
        });
      });
  }
  // 省略...
}
复制代码

Flutter Stack 控件实现探探卡片布局文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

  1. 卡片换位动画

卡片换位动画就是将最前面的卡片移除可视区,将中间的卡片移动到最前面,将最后的卡片移动到中间,然后新建一个最后面的卡片。在卡片更换位置的同时需要改变卡片的尺寸,位置动画和尺寸动画同时进行。首先定义每个卡片运动时的动画值文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

/// 卡片尺寸
class CardSizes {
  static Size front(BoxConstraints constraints) {
    return Size(constraints.maxWidth * 0.9, constraints.maxHeight * 0.9);
  }

  static Size middle(BoxConstraints constraints) {
    return Size(constraints.maxWidth * 0.85, constraints.maxHeight * 0.9);
  }

  static Size back(BoxConstraints constraints) {
    return Size(constraints.maxWidth * 0.8, constraints.maxHeight * .9);
  }
}

/// 卡片位置
class CardAlignments {
  static Alignment front = Alignment(0.0, -0.5);
  static Alignment middle = Alignment(0.0, 0.0);
  static Alignment back = Alignment(0.0, 0.5);
}

/// 卡片运动动画
class CardAnimations {
  /// 最前面卡片的消失动画值
  static Animation<Alignment> frontCardDisappearAnimation(
    AnimationController parent,
    Alignment beginAlignment,
  ) {
    return AlignmentTween(
      begin: beginAlignment,
      end: Alignment(
        beginAlignment.x > 0
            ? beginAlignment.x + 30.0
            : beginAlignment.x - 30.0,
        0.0,
      ),
    ).animate(
      CurvedAnimation(
        parent: parent,
        curve: Interval(0.0, 0.5, curve: Curves.easeIn),
      ),
    );
  }

  /// 中间卡片位置变换动画值
  static Animation<Alignment> middleCardAlignmentAnimation(
    AnimationController parent,
  ) {
    return AlignmentTween(
      begin: CardAlignments.middle,
      end: CardAlignments.front,
    ).animate(
      CurvedAnimation(
        parent: parent,
        curve: Interval(0.2, 0.5, curve: Curves.easeIn),
      ),
    );
  }

  /// 中间卡片尺寸变换动画值
  static Animation<Size> middleCardSizeAnimation(
    AnimationController parent,
    BoxConstraints constraints,
  ) {
    return SizeTween(
      begin: CardSizes.middle(constraints),
      end: CardSizes.front(constraints),
    ).animate(
      CurvedAnimation(
        parent: parent,
        curve: Interval(0.2, 0.5, curve: Curves.easeIn),
      ),
    );
  }

  /// 最后面卡片位置变换动画值
  static Animation<Alignment> backCardAlignmentAnimation(
    AnimationController parent,
  ) {
    return AlignmentTween(
      begin: CardAlignments.back,
      end: CardAlignments.middle,
    ).animate(
      CurvedAnimation(
        parent: parent,
        curve: Interval(0.4, 0.7, curve: Curves.easeIn),
      ),
    );
  }

  /// 最后面卡片尺寸变换动画值
  static Animation<Size> backCardSizeAnimation(
    AnimationController parent,
    BoxConstraints constraints,
  ) {
    return SizeTween(
      begin: CardSizes.back(constraints),
      end: CardSizes.middle(constraints),
    ).animate(
      CurvedAnimation(
        parent: parent,
        curve: Interval(0.4, 0.7, curve: Curves.easeIn),
      ),
    );
  }
}
复制代码

使用一个 AnimationController 控制动画运行,动画运行时在卡片上应用以上的动画值,否则使用卡片默认的位置和尺寸。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

class _MyAppState extends State<MyApp> with TickerProviderStateMixin {

  // 省略...

  // 卡片位置变换动画控制器
  AnimationController _cardChangeController;

  //  前面的卡片,使用 Align 定位
  Widget _frontCard(BoxConstraints constraints) {
    // 判断动画是否在运行
    bool forward = _cardChangeController.status == AnimationStatus.forward;

    // 使用 Transform.rotate 旋转卡片
    Widget rotate = Transform.rotate(
      angle: (pi / 180.0) * _frontCardRotation,
      // 使用 SizedBox 确定卡片尺寸
      child: SizedBox.fromSize(
        size: CardSizes.front(constraints),
        child: Container(
          color: Colors.blue,
        ),
      ),
    );

    // 在动画运行时使用动画值
    if (forward) {
      return Align(
        alignment: CardAnimations.frontCardDisappearAnimation(
          _cardChangeController,
          _frontCardAlignment,
        ).value,
        child: rotate,
      );
    }

    // 否则使用默认值
    return Align(
      alignment: _frontCardAlignment,
      child: rotate,
    );
  }

  // 中间的卡片,使用 Align 定位
  Widget _middleCard(BoxConstraints constraints) {
    // 判断动画是否在运行
    bool forward = _cardChangeController.status == AnimationStatus.forward;
    Widget child = Container(color: Colors.red);

    // 在动画运行时使用动画值
    if (forward) {
      return Align(
        alignment: CardAnimations.middleCardAlignmentAnimation(
          _cardChangeController,
        ).value,
        child: SizedBox.fromSize(
          size: CardAnimations.middleCardSizeAnimation(
            _cardChangeController,
            constraints,
          ).value,
          child: child,
        ),
      );
    }

    // 否则使用默认值
    return Align(
      alignment: CardAlignments.middle,
      child: SizedBox.fromSize(
        size: CardSizes.middle(constraints),
        child: child,
      ),
    );
  }

  // 后面的卡片,使用 Align 定位
  Widget _backCard(BoxConstraints constraints) {
    // 判断动画是否在运行
    bool forward = _cardChangeController.status == AnimationStatus.forward;
    Widget child = Container(color: Colors.green);

    // 在动画运行时使用动画值
    if (forward) {
      return Align(
        alignment: CardAnimations.backCardAlignmentAnimation(
          _cardChangeController,
        ).value,
        child: SizedBox.fromSize(
          size: CardAnimations.backCardSizeAnimation(
            _cardChangeController,
            constraints,
          ).value,
          child: child,
        ),
      );
    }

    // 否则使用默认值
    return Align(
      alignment: CardAlignments.back,
      child: SizedBox.fromSize(
        size: CardSizes.back(constraints),
        child: child,
      ),
    );
  }

  // 改变位置的动画
  void _runChangeOrderAnimation() {
    _cardChangeController.reset();
    _cardChangeController.forward();
  }

  // 省略...

  @override
  void initState() {
    super.initState();
    // 省略...

    // 初始化卡片换位动画控制器
    _cardChangeController = AnimationController(
      duration: Duration(milliseconds: 1000),
      vsync: this,
    )
      ..addListener(() => setState(() {}))
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          // 动画运行结束后重置位置和旋转
          _frontCardRotation = 0.0;
          _frontCardAlignment = CardAlignments.front;
          setState(() {});
        }
      });
  }
  // 省略...
}
复制代码

Flutter Stack 控件实现探探卡片布局文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

数据更新

可以看到动画运行之后三张卡片都恢复了默认的位置和尺寸,而需要的效果是当卡片换位动画完成后三张卡片的数据会改变,所以还需要在动画之后进行数据处理。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

创建一个数组保存全部子项目,使用一个索引更新最前面卡片的子项索引,在卡片换位动画结束后索引值加一。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

List<String> images = [
  'https://gank.io/images/5ba77f3415b44f6c843af5e149443f94',
  'https://gank.io/images/02eb8ca3297f4931ab64b7ebd7b5b89c',
  'https://gank.io/images/31f92f7845f34f05bc10779a468c3c13',
  'https://gank.io/images/b0f73f9527694f44b523ff059d8a8841',
  'https://gank.io/images/1af9d69bc60242d7aa2e53125a4586ad',
];

// 生成卡片数组
List<Widget> cards = List.generate(
  images.length,
  (int index) {
    return Container(
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16.0),
        boxShadow: [
          BoxShadow(
            offset: Offset(0, 17),
            blurRadius: 23.0,
            spreadRadius: -13.0,
            color: Colors.black54,
          )
        ],
      ),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(16.0),
        child: Image.network(
          images[index],
          fit: BoxFit.cover,
        ),
      ),
    );
  },
);

void main() {
  // 使用生成的卡片数组
  runApp(MyApp(cards: cards));
}

class MyApp extends StatefulWidget {
  final List<Widget> cards;

  const MyApp({@required this.cards});

  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with TickerProviderStateMixin {
  // 卡片列表
  final List<Widget> _cards = [];
  // 最前面卡片的索引
  int _frontCardIndex = 0;

  // 省略...

  //  前面的卡片,使用 Align 定位
  Widget _frontCard(BoxConstraints constraints) {
    // 判断是否还有卡片
    Widget card =
        _frontCardIndex < _cards.length ? _cards[_frontCardIndex] : Container();
    bool forward = _cardChangeController.status == AnimationStatus.forward;

    // 使用 Transform.rotate 旋转卡片
    Widget rotate = Transform.rotate(
      angle: (pi / 180.0) * _frontCardRotation,
      // 使用 SizedBox 确定卡片尺寸
      child: SizedBox.fromSize(
        size: CardSizes.front(constraints),
        // 使用数组中的子项
        child: card,
      ),
    );

   // 省略...
  }

  // 中间的卡片,使用 Align 定位
  Widget _middleCard(BoxConstraints constraints) {
    // 判断是否还有两张卡片
    Widget card = _frontCardIndex < _cards.length - 1
        ? _cards[_frontCardIndex + 1]
        : Container();
    // 省略...
  }

  // 后面的卡片,使用 Align 定位
  Widget _backCard(BoxConstraints constraints) {
    // 判断数组中是否还有三张卡片
    Widget card = _frontCardIndex < _cards.length - 2
        ? _cards[_frontCardIndex + 2]
        : Container();
    // 省略...
  }

  // 省略...

  @override
  void initState() {
    super.initState();
    // 初始化卡片数组
    _cards.addAll(widget.cards);

    // 省略...

    // 初始化卡片换位动画控制器
    _cardChangeController = AnimationController(
      duration: Duration(milliseconds: 1000),
      vsync: this,
    )
      ..addListener(() => setState(() {}))
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          // 动画结束后将最前面卡片的索引向前移动一位
          _frontCardIndex++;
          // 动画运行结束后重置位置和旋转
          _frontCardRotation = 0.0;
          _frontCardAlignment = CardAlignments.front;
          setState(() {});
        }
      });
  }

 // 省略...

return Stack(
  children: [
    // 省略...

    // 使用一个占满父元素的 GestureDetector 监听手指移动
    // 如果动画在运行中就不在响应手势
    _cardChangeController.status != AnimationStatus.forward
        ? SizedBox.expand(
            child: GestureDetector(
              // 省略...
            )
          )
        : IgnorePointer(),
  ],
}

复制代码

至此整个布局就实现了 ?文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

总结

这个布局的关键点在于文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

  1. 三张卡片的定位
  2. 监听手势更新最前面卡片的位置
  3. 卡片的换位动画和回弹动画

作者已经封装了这个插件,地址是 https://pub.dev/packages/tcard 欢迎使用。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

作者:ColdStone
链接:https://juejin.im/post/5eece33fe51d4573ef06aa8f
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html

文章源自菜鸟学院-https://www.cainiaoxueyuan.com/xcx/19466.html
  • 本站内容整理自互联网,仅提供信息存储空间服务,以方便学习之用。如对文章、图片、字体等版权有疑问,请在下方留言,管理员看到后,将第一时间进行处理。
  • 转载请务必保留本文链接:https://www.cainiaoxueyuan.com/xcx/19466.html

Comment

匿名网友 填写信息

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定