跳至主要內容

使用物理模擬來製作 widget 動畫

物理模擬可以使應用程式的互動感覺真實且具有互動性。例如,您可能希望製作 widget 動畫,使其表現得像是連接到彈簧或以重力墜落。

這個範例示範如何使用彈簧模擬將 widget 從拖曳點移回中心。

這個範例使用以下步驟

  1. 設定動畫控制器
  2. 使用手勢移動 widget
  3. 製作 widget 動畫
  4. 計算速度以模擬彈簧運動

步驟 1:設定動畫控制器

#

從一個名為 DraggableCard 的 stateful widget 開始

dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: PhysicsCardDragDemo()));
}

class PhysicsCardDragDemo extends StatelessWidget {
  const PhysicsCardDragDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const DraggableCard(
        child: FlutterLogo(
          size: 128,
        ),
      ),
    );
  }
}

class DraggableCard extends StatefulWidget {
  const DraggableCard({required this.child, super.key});

  final Widget child;

  @override
  State<DraggableCard> createState() => _DraggableCardState();
}

class _DraggableCardState extends State<DraggableCard> {
  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Align(
      child: Card(
        child: widget.child,
      ),
    );
  }
}

使 _DraggableCardState 類別繼承自 SingleTickerProviderStateMixin。然後在 initState 中建立一個 AnimationController,並將 vsync 設為 this

dart
class _DraggableCardState extends State<DraggableCard> {
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller =
        AnimationController(vsync: this, duration: const Duration(seconds: 1));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

步驟 2:使用手勢移動 Widget

#

讓 widget 在拖曳時移動,並將一個 Alignment 欄位新增到 _DraggableCardState 類別

dart
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  Alignment _dragAlignment = Alignment.center;

新增一個處理 onPanDownonPanUpdateonPanEnd 回呼的 GestureDetector。要調整對齊方式,請使用 MediaQuery 來取得 widget 的大小,然後除以 2。(這會將「拖曳的像素」單位轉換為 Align 使用的座標。)然後,將 Align widget 的 alignment 設定為 _dragAlignment

dart
@override
Widget build(BuildContext context) {
  return Align(
    child: Card(
      child: widget.child,
  var size = MediaQuery.of(context).size;
  return GestureDetector(
    onPanDown: (details) {},
    onPanUpdate: (details) {
      setState(() {
        _dragAlignment += Alignment(
          details.delta.dx / (size.width / 2),
          details.delta.dy / (size.height / 2),
        );
      });
    },
    onPanEnd: (details) {},
    child: Align(
      alignment: _dragAlignment,
      child: Card(
        child: widget.child,
      ),
    ),
  );
}

步驟 3:為 Widget 添加動畫效果

#

當 widget 被釋放時,它應該彈回中心。

新增一個 Animation<Alignment> 欄位和一個 _runAnimation 方法。此方法定義一個 Tween,它在 widget 被拖曳到的點與中心點之間進行插值。

dart
class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Alignment> _animation;
  Alignment _dragAlignment = Alignment.center;
dart
void _runAnimation() {
  _animation = _controller.drive(
    AlignmentTween(
      begin: _dragAlignment,
      end: Alignment.center,
    ),
  );
  _controller.reset();
  _controller.forward();
}

接下來,當 AnimationController 產生一個值時,更新 _dragAlignment

dart
@override
void initState() {
  super.initState();
  _controller =
      AnimationController(vsync: this, duration: const Duration(seconds: 1));
  _controller.addListener(() {
    setState(() {
      _dragAlignment = _animation.value;
    });
  });
}

接下來,讓 Align widget 使用 _dragAlignment 欄位

dart
child: Align(
  alignment: _dragAlignment,
  child: Card(
    child: widget.child,
  ),
),

最後,更新 GestureDetector 以管理動畫控制器

dart
return GestureDetector(
  onPanDown: (details) {},
  onPanDown: (details) {
    _controller.stop();
  },
  onPanUpdate: (details) {
    // ...
  },
  onPanEnd: (details) {},
  onPanEnd: (details) {
    _runAnimation();
  },
  child: Align(

步驟 4:計算速度以模擬彈簧運動

#

最後一步是做一些數學計算,以計算 widget 在拖曳完成後的速度。這樣做是為了讓 widget 在彈回之前,能夠以真實的速度繼續移動。(_runAnimation 方法已經透過設定動畫的起始和結束對齊方式來設定方向。)

首先,匯入 physics 套件

dart
import 'package:flutter/physics.dart';

onPanEnd 回呼提供一個 DragEndDetails 物件。此物件提供指標停止接觸螢幕時的速度。速度單位為每秒像素,但 Align widget 不使用像素。它使用的座標值介於 [-1.0, -1.0] 和 [1.0, 1.0] 之間,其中 [0.0, 0.0] 代表中心。步驟 2 中計算的 size 用於將像素轉換為此範圍內的座標值。

最後,AnimationController 有一個 animateWith() 方法,可以傳入一個 SpringSimulation

dart
/// Calculates and runs a [SpringSimulation].
void _runAnimation(Offset pixelsPerSecond, Size size) {
  _animation = _controller.drive(
    AlignmentTween(
      begin: _dragAlignment,
      end: Alignment.center,
    ),
  );
  // Calculate the velocity relative to the unit interval, [0,1],
  // used by the animation controller.
  final unitsPerSecondX = pixelsPerSecond.dx / size.width;
  final 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);

  _controller.animateWith(simulation);
}

別忘了使用速度和大小呼叫 _runAnimation()

dart
onPanEnd: (details) {
  _runAnimation(details.velocity.pixelsPerSecond, size);
},

互動範例

#
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';

void main() {
  runApp(const MaterialApp(home: PhysicsCardDragDemo()));
}

class PhysicsCardDragDemo extends StatelessWidget {
  const PhysicsCardDragDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const DraggableCard(
        child: FlutterLogo(
          size: 128,
        ),
      ),
    );
  }
}

/// A draggable card that moves back to [Alignment.center] when it's
/// released.
class DraggableCard extends StatefulWidget {
  const DraggableCard({required this.child, super.key});

  final Widget child;

  @override
  State<DraggableCard> createState() => _DraggableCardState();
}

class _DraggableCardState extends State<DraggableCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  /// The alignment of the card as it is dragged or being animated.
  ///
  /// While the card is being dragged, this value is set to the values computed
  /// in the GestureDetector onPanUpdate callback. If the animation is running,
  /// this value is set to the value of the [_animation].
  Alignment _dragAlignment = Alignment.center;

  late Animation<Alignment> _animation;

  /// Calculates and runs a [SpringSimulation].
  void _runAnimation(Offset pixelsPerSecond, Size size) {
    _animation = _controller.drive(
      AlignmentTween(
        begin: _dragAlignment,
        end: Alignment.center,
      ),
    );
    // Calculate the velocity relative to the unit interval, [0,1],
    // used by the animation controller.
    final unitsPerSecondX = pixelsPerSecond.dx / size.width;
    final 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);

    _controller.animateWith(simulation);
  }

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);

    _controller.addListener(() {
      setState(() {
        _dragAlignment = _animation.value;
      });
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return GestureDetector(
      onPanDown: (details) {
        _controller.stop();
      },
      onPanUpdate: (details) {
        setState(() {
          _dragAlignment += Alignment(
            details.delta.dx / (size.width / 2),
            details.delta.dy / (size.height / 2),
          );
        });
      },
      onPanEnd: (details) {
        _runAnimation(details.velocity.pixelsPerSecond, size);
      },
      child: Align(
        alignment: _dragAlignment,
        child: Card(
          child: widget.child,
        ),
      ),
    );
  }
}