用 Flutter 实现探探卡片布局

2020-06-20 00:22:28 +08:00
 xrr2016

前言

前几天写了一个 Fluter 插件 tcard,用来实现类似于探探卡片的布局。效果如下,本文讲解如何使用 Stack 控件实现这个布局。

在线查看

初识 Stack

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

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)

布局思路

要使用 Stack 实现这个卡片布局的大致思路如下

  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(),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
  1. 对子控件分别定位并设置其尺寸

定位需要设置 Align 控件的 alignment 属性,传入一个 Alignment(x, y) 进行设置。设置尺寸需要使用 LayoutBuilder 获取当前父容器的尺寸,然后根据容器尺寸进行计算。

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),
                  ],
                );
              },
            ),
          ),
        ),
      ),
    );
  }
}

  1. 更新最前面卡片位置

Stack 容器添加一个 GestureDetector,手指在屏幕上移动时更新最前面卡片的位置。

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) {},
                      ),
                    ),
                  ],
                );
              },
            ),
          ),
        ),
      ),
    );
  }
}

卡片动画

这个布局有三种动画,最前面卡片移开的动画;后面两张卡片位置和尺寸变化的动画;最前面卡片回到原位的动画。

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

在手指离开屏幕时判断卡片横轴的移动距离,如果最前面的卡片横轴移动距离超过限制就运行换位动画,否则运行回弹动画。

// 改变位置的动画
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 传递给动画控制器,让动画模拟运行。

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;
        });
      });
  }
  // 省略...
}
  1. 卡片换位动画

卡片换位动画就是将最前面的卡片移除可视区,将中间的卡片移动到最前面,将最后的卡片移动到中间,然后新建一个最后面的卡片。在卡片更换位置的同时需要改变卡片的尺寸,位置动画和尺寸动画同时进行。首先定义每个卡片运动时的动画值

/// 卡片尺寸
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 控制动画运行,动画运行时在卡片上应用以上的动画值,否则使用卡片默认的位置和尺寸。

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(() {});
        }
      });
  }
  // 省略...
}

数据更新

主题内容长度不能超过 20000 个字符。。。超长限制,全文地址在此 用 Flutter 实现探探卡片布局

2272 次点击
所在节点    Flutter
6 条回复
suzic
2020-06-20 01:59:04 +08:00
感谢分享,收藏了
Roung
2020-06-20 06:59:05 +08:00
直接感谢了,把东西码到 v2 也不容易。
notEnough
2020-06-20 19:41:03 +08:00
学习了,大佬牛逼
xrr2016
2020-06-20 23:52:50 +08:00
@notEnough 哈哈,取笑了,不是大佬
dahhd
2020-08-08 20:10:00 +08:00
牛逼,大佬带带我,最近也想要深入这方面开发
xrr2016
2020-08-08 22:21:27 +08:00
@dahhd 大佬个屁啊...我也只是感兴趣而已;可以加我微信 xrr20160808 拉你进大佬群学习

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/683216

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX