likes
comments
collection
share

Flutter自定义View之拖拽回弹控件

作者站长头像
站长
· 阅读数 17

简介

Flutter作为UI框架,和其他语言一样支持自定义View。本文将详细介绍怎么使用Flutter实现一个随意拖动的悬浮控件。片尾附源码。

场景

作为一个程序员,听歌几乎是写代码的必备。一般的听歌软件都会有这样一个效果,当我们听歌时,打开另一个页面时,正在播放歌曲的页面以一个CD盘的形式悬浮在页面上,可以随意拖拽,点击CD盘又会重新回到当前播放的页面。

先看效果:

Flutter自定义View之拖拽回弹控件

实现功能

本篇文章主要讲解如何使用Flutter来实现这样一个效果。

需要实现的功能如下:

  1. 支持自定义初始位置。
  2. 随意拖动悬浮控件。
  3. 控件支持回弹效果。
  4. 控件不能滑出页面。

功能分析

初始位置

在Flutter定义控件的位置一般使用Positioned控件。通过指定left和top属性来初始化悬浮按钮的位置。

  1. 首先需要定义Offset属性来保存控件的初始化位置变量。
late Offset _offset;
  1. 然后将初始化值赋值给Positioned控件。
Positioned(
  left: _offset.dx,
  top: _offset.dy,
  ....
)

这样就实现了初始化控件的位置。

随意拖拽

要想实现拖拽功能,在Flutter中就不得不使用GestureDetector 了,GestureDetector提供了大量的手势操作回调,简化用户的使用。这里主要使用GestureDetector 的

onTap :单击,

onPanStart:滑动开始,

onPanUpdate:滑动更新,

onPanEnd:滑动结束

这几个方法。通过计算手指在屏幕上的滑动计算滑动位置,然后更新控件UI即可完成。

GestureDetector(
  //滑动开始
  onPanStart: (DragStartDetails details) {
    _animationController.stop(canceled: true);
    _selfWidth = context.size?.width ??0;
    _selfHeight =context.size?.height ??0;
  },
  //滑动更新
  onPanUpdate: (DragUpdateDetails details) {
    _offset += Offset(details.delta.dx, details.delta.dy);
    _offsetTop += details.delta.dy;
    _offsetLeft += details.delta.dx;
    if (_offsetTop < 0) _offsetTop = 0 + (widget.marginTop ?? 0.0);
    if (_offsetLeft < 0) _offsetLeft = 0 + (widget.marginLeft ?? 0.0);
    if (_offsetLeft + _selfWidth > _screenWidth) {
      _offsetLeft = _screenWidth - _selfWidth;
    }
    if (_offsetTop + _selfHeight > _screenHeight) {
      _offsetTop = _screenHeight - _selfHeight;
    }
    //重绘UI
    setState(() {});
  },
  //滑动结束
  onPanEnd: (DragEndDetails details) {
    if (_offsetLeft > _screenWidth / 2 - _selfWidth / 2) {
      _offsetLeft =
          _screenWidth - _selfWidth - (widget.marginRight ?? 0.0);
    } else {
      _offsetLeft = 0.00 + (widget.marginLeft ?? 0.0);
    }

回弹效果

处理完滑动手势,要增加回弹效果,就要使用Flutter提供的动画。这里使用的是弹簧效果。

  1. 定义弹簧动画效果。
final double? mass; //弹簧质量
  final double? stiffness; //弹簧系数
  final double? damping; //阻尼系数

_animation = _animationController.drive(
    Tween<Offset>(begin: _offset, end: Offset(_offsetLeft, _offsetTop)));
SpringSimulation simulation = SpringSimulation(
  SpringDescription(mass: _mass, stiffness: _stiffness, damping: _damping),
  0,
  1,
  -Offset(pixelsPerSecond.dx / size.width, pixelsPerSecond.dy / size.height)
      .distance,
);
_animationController.animateWith(simulation);

可通过调节弹簧质量,弹簧系数,阻尼系数来控制得到不同的弹簧效果。

  1. 最后使用动画控制器来实时监听弹簧系数值变化,调用setState进行UI重绘。
_animationController = AnimationController.unbounded(vsync: this);
_animationController.addListener(() {
  _offset = _animation.value;
  setState(() {});
});

边界处理

最后我们来处理一下边界问题,如果不小心将控件滑出屏幕 ,那岂不是使用不了。所以当将控件滑出屏幕时,控件能够自动滑到屏幕两边。这里我们需要再手势滑动过程中,判断是否超出了屏幕边界。

 onPanUpdate: (DragUpdateDetails details) {
          _offset += Offset(details.delta.dx, details.delta.dy);
          _offsetTop += details.delta.dy;
          _offsetLeft += details.delta.dx;
          if (_offsetTop < 0) _offsetTop = 0 + (widget.marginTop ?? 0.0);
          if (_offsetLeft < 0) _offsetLeft = 0 + (widget.marginLeft ?? 0.0);
          if (_offsetLeft + _selfWidth > _screenWidth) {
            _offsetLeft = _screenWidth - _selfWidth;
          }
          if (_offsetTop + _selfHeight > _screenHeight) {
            _offsetTop = _screenHeight - _selfHeight;
          }
          setState(() {});
        },

通过实时监听手指在屏幕的偏移位置来限制控件的位置。

难点

计算屏幕高度:

  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      final RenderBox parentRenderBox =
      widget.parentKey.currentContext?.findRenderObject() as RenderBox;
      _screenHeight = parentRenderBox.size.height - (widget.marginBottom ?? 0.0);
    });

当页面绘制完成后通过addPostFrameCallback回调中获取父控件的高度。

源码如下:

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

class OverlayButton extends StatefulWidget {
  const OverlayButton(
      {Key? key,
      required this.child,
      required this.parentKey,
      required this.initOffset,
      required this.onPressed,
      this.marginLeft,
      this.marginTop,
      this.marginBottom,
      this.marginRight,
      this.mass,
      this.stiffness,
      this.damping})
      : super(key: key);
  final Widget child; //子widget
  final Offset initOffset; //初始位置
  final GlobalKey parentKey; //父控件的key
  final VoidCallback onPressed; //点击事件
  final double? marginLeft; //外边距,距离左边
  final double? marginTop; //外边距,距离上边
  final double? marginRight; //外边距,距离右边
  final double? marginBottom; //外边距,距离下边
  final double? mass; //弹簧质量
  final double? stiffness; //弹簧系数
  final double? damping; //阻尼系数

  @override
  State createState() => _OverlayButtonState();
}

class _OverlayButtonState extends State<OverlayButton>
    with SingleTickerProviderStateMixin {
  late double _offsetLeft;
  late double _offsetTop;
  late AnimationController _animationController;
  late Animation<Offset> _animation;
  late Offset _offset;
  late double _screenWidth;
  late double _screenHeight;
  late double _selfWidth = 0.0;
  late double _selfHeight = 0.0;
  late double _mass;

  late double _stiffness;
  late double _damping;

  @override
  void initState() {
    super.initState();
    _mass = widget.mass ?? 20;
    _stiffness = widget.stiffness ?? 400;
    _damping = widget.damping ?? 0.75;
    _offsetLeft = widget.initOffset.dx;
    _offsetTop = widget.initOffset.dy;
    _offset = widget.initOffset;
    _animationController = AnimationController.unbounded(vsync: this);
    _animationController.addListener(() {
      _offset = _animation.value;
      setState(() {});
    });
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      final RenderBox parentRenderBox =
      widget.parentKey.currentContext?.findRenderObject() as RenderBox;
      _screenHeight = parentRenderBox.size.height - (widget.marginBottom ?? 0.0);
    });
  }

  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    _screenWidth = size.width;

    return Positioned(
      left: _offset.dx,
      top: _offset.dy,
      child: GestureDetector(
        onPanStart: (DragStartDetails details) {
          _animationController.stop(canceled: true);
          _selfWidth = context.size?.width ??0;
          _selfHeight =context.size?.height ??0;
        },
        onPanUpdate: (DragUpdateDetails details) {
          _offset += Offset(details.delta.dx, details.delta.dy);
          _offsetTop += details.delta.dy;
          _offsetLeft += details.delta.dx;
          if (_offsetTop < 0) _offsetTop = 0 + (widget.marginTop ?? 0.0);
          if (_offsetLeft < 0) _offsetLeft = 0 + (widget.marginLeft ?? 0.0);
          if (_offsetLeft + _selfWidth > _screenWidth) {
            _offsetLeft = _screenWidth - _selfWidth;
          }
          if (_offsetTop + _selfHeight > _screenHeight) {
            _offsetTop = _screenHeight - _selfHeight;
          }
          setState(() {});
        },
        onPanEnd: (DragEndDetails details) {
          if (_offsetLeft > _screenWidth / 2 - _selfWidth / 2) {
            _offsetLeft =
                _screenWidth - _selfWidth - (widget.marginRight ?? 0.0);
          } else {
            _offsetLeft = 0.00 + (widget.marginLeft ?? 0.0);
          }

          startAnimation(details.velocity.pixelsPerSecond, size);
        },
        onTap: widget.onPressed,
        child: widget.child,
      ),
    );
  }

  void startAnimation(Offset pixelsPerSecond, Size size) {
    _animation = _animationController.drive(
        Tween<Offset>(begin: _offset, end: Offset(_offsetLeft, _offsetTop)));
    SpringSimulation simulation = SpringSimulation(
      SpringDescription(mass: _mass, stiffness: _stiffness, damping: _damping),
      0,
      1,
      -Offset(pixelsPerSecond.dx / size.width, pixelsPerSecond.dy / size.height)
          .distance,
    );
    _animationController.animateWith(simulation);
  }
  @override
  void dispose() {
    super.dispose();
    _animationController.dispose();
  }
}

本文简单实现了一个拖拽回弹的控件,更多好玩的效果需要动手去实现。