Flutter 实现探探卡片布局

发布于 1周前 作者 yibo5220 来自 Flutter

前言

前几天写了一个 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](/user/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(() =&gt; setState(() {}))
  ..addStatusListener((status) {
    if (status == AnimationStatus.completed) {
      // 动画运行结束后重置位置和旋转
      _frontCardRotation = 0.0;
      _frontCardAlignment = CardAlignments.front;
      setState(() {});
    }
  });

} // 省略… }

数据更新

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


Flutter 实现探探卡片布局

更多关于Flutter 实现探探卡片布局的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html

7 回复

感谢分享,收藏了

更多关于Flutter 实现探探卡片布局的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


直接感谢了,把东西码到 v2 也不容易。

学习了,大佬牛逼

哈哈,取笑了,不是大佬

牛逼,大佬带带我,最近也想要深入这方面开发

大佬个屁啊…我也只是感兴趣而已;可以加我微信 xrr20160808 拉你进大佬群学习

在Flutter中实现探探卡片布局(Tinder-like card layout),你可以使用SwipeableStack或类似的自定义组件来实现左右滑动卡片的效果。以下是一个简要的实现思路:

  1. 依赖库: 可以使用flutter_swiperflutter_tinder_card等第三方库,这些库已经封装好了滑动卡片的功能,可以减少开发工作量。

  2. 自定义组件: 如果希望更灵活地控制动画效果或布局,可以自定义一个Stack组件,结合GestureDetectorAnimatedContainer来实现滑动动画。

  3. 数据绑定: 使用Flutter的状态管理(如ProviderRiverpodGetX)来管理卡片数据,以便在滑动时更新视图。

  4. 动画效果: 使用AnimationControllerTween来控制卡片滑动时的动画效果,如缩放、旋转等。

  5. 处理滑动事件: 在GestureDetectoronPanUpdate回调中处理滑动距离,当滑动距离达到一定阈值时,触发左滑或右滑的事件,同时更新卡片堆栈。

  6. 边界处理: 在卡片滑动到边界时,触发加载更多卡片或显示“没有更多卡片”的提示。

通过上述步骤,你可以在Flutter中实现一个类似于探探的卡片布局。需要注意的是,滑动动画的流畅性和响应速度对于用户体验至关重要,因此在实现过程中要特别注意性能优化。

回到顶部
AI 助手
你好,我是IT营的 AI 助手
您可以尝试点击下方的快捷入口开启体验!