欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 美食 > Flutter 仿iOS桌面悬浮球效果

Flutter 仿iOS桌面悬浮球效果

2025/2/23 0:38:14 来源:https://blog.csdn.net/HQ_LIN/article/details/141717188  浏览:    关键词:Flutter 仿iOS桌面悬浮球效果

Flutter 仿iOS桌面悬浮球效果

  • 效果图
  • 可拖动的基础按钮
  • 自定义一个可动画展开关闭的路由
  • 使用->创建OverlayEntry
  • demo

效果图

RPReplay_Final1724998086

可拖动的基础按钮

class DraggableFloatingActionButton extends StatefulWidget {final Widget child;final Size childSize;final Offset initialOffset;final VoidCallback onPressed;final double padding;final Function callBack;BuildContext parentContext;DraggableFloatingActionButton({required this.child,required this.initialOffset,required this.onPressed,required this.callBack,// required this.parentKey,required this.parentContext,required this.childSize,this.padding = 20,});@overrideState<StatefulWidget> createState() => _DraggableFloatingActionButtonState();
}class _DraggableFloatingActionButtonState extends State<DraggableFloatingActionButton>with TickerProviderStateMixin, WidgetsBindingObserver {//托动按钮使用的Keyfinal GlobalKey _childKey = GlobalKey();bool _isDragging = false;bool _isAnimating = false;late Offset _offset;late Offset _newOffset;late Offset _minOffset;late Offset _maxOffset;late Size _childSize;late Size _parentSize;late final AnimationController _controller;@overridevoid initState() {super.initState();// 托动按钮的初始位置_offset = widget.initialOffset;_newOffset = widget.initialOffset;_childSize = widget.childSize;_parentSize = Size.zero;// 添加视图监听WidgetsBinding.instance.addObserver(this);WidgetsBinding.instance.addPostFrameCallback(_initBoundary);_controller = AnimationController(duration: const Duration(milliseconds: 250),vsync: this,);_controller.addListener(() {debugPrint('status=${_controller.status}');if (_controller.status == AnimationStatus.completed) {// setState(() {//   _controller.stop();//   _isAnimating = false;// });}});}@overridevoid dispose() {WidgetsBinding.instance.removeObserver(this);super.dispose();}double _keyboardHeight = 0;@overridevoid didChangeMetrics() {super.didChangeMetrics();if (Platform.isAndroid) {WidgetsBinding.instance.addPostFrameCallback((_) {if (mounted) {_changeMenu();}});} else {_changeMenu();}}_changeMenu() {// 软键盘高度double newKeyboardHeight = MediaQuery.of(context).viewInsets.bottom;double pageHeight = MediaQuery.of(context).size.height;double originH = pageHeight - _offset.dy;if (newKeyboardHeight <= _keyboardHeight && originH > newKeyboardHeight) {return;}_keyboardHeight = newKeyboardHeight;double botX = (pageHeight - _keyboardHeight) - _childSize.height * 1.2 - 36;if (botX <= originH) {return;}Offset keyboard = Offset(_offset.dx, botX);_keybordUpdatePosition(keyboard);setState(() {_isDragging = true;_isAnimating = false;});}// 页面第一帧绘制完成后调用void _initBoundary(_) {// 获取获取组件的 RenderBoxfinal RenderBox parentRenderBox = widget.parentContext.findRenderObject() as RenderBox;// 获取托动按钮组件的 RenderBoxtry {// 分别获取两者的大小 从而计算边界final Size parentSize = parentRenderBox.size;_parentSize = parentSize;setState(() {_minOffset = Offset(0, 0);_maxOffset = Offset(parentSize.width - widget.childSize.width, parentSize.height - widget.childSize.height);debugPrint('全局按钮_initBoundary, _minOffset=$_minOffset, _maxOffset=$_maxOffset, parentSize=$parentSize, size=${widget.childSize}');});} catch (e) {print('catch: $e');}}/// 计算按钮位置void _updatePosition(PointerMoveEvent pointerMoveEvent) {double newOffsetX = _offset.dx + pointerMoveEvent.delta.dx;double newOffsetY = _offset.dy + pointerMoveEvent.delta.dy;if (newOffsetX < _minOffset.dx) {newOffsetX = _minOffset.dx;} else if (newOffsetX > _maxOffset.dx) {newOffsetX = _maxOffset.dx;}if (newOffsetY < _minOffset.dy) {newOffsetY = _minOffset.dy;} else if (newOffsetY > _maxOffset.dy) {newOffsetY = _maxOffset.dy;}setState(() {_offset = Offset(newOffsetX, newOffsetY);debugPrint('_offset=$_offset, pointerMoveEvent.delta.dx=${pointerMoveEvent.delta.dx}, pointerMoveEvent.delta.dy=${pointerMoveEvent.delta.dy}');});}/// 根据键盘显示计算按钮位置void _keybordUpdatePosition(Offset keyboardOffset) {double newOffsetX = keyboardOffset.dx;double newOffsetY = keyboardOffset.dy;if (newOffsetX < _minOffset.dx) {newOffsetX = _minOffset.dx;} else if (newOffsetX > _maxOffset.dx) {newOffsetX = _maxOffset.dx;}if (newOffsetY < _minOffset.dy) {newOffsetY = _minOffset.dy;} else if (newOffsetY > _maxOffset.dy) {newOffsetY = _maxOffset.dy;}setState(() {_offset = Offset(newOffsetX, newOffsetY);debugPrint('键盘_offset=$_offset, pointerMoveEvent.delta.dx=${keyboardOffset.dx}, pointerMoveEvent.delta.dy=${keyboardOffset.dy}');});}///可托动的悬浮按钮@overrideWidget build(BuildContext context) {if ((!_isDragging && _isAnimating)) {_isAnimating = false;double childHeight = _childSize.height;double childWidth = _childSize.width;var beginRect = RelativeRect.fromSize(Rect.fromLTWH(_offset.dx, _offset.dy, childWidth, childHeight),_parentSize,);var endRect = RelativeRect.fromSize(Rect.fromLTWH(_newOffset.dx,_newOffset.dy,childWidth,childHeight,),_parentSize,);final rectAnimation = RelativeRectTween(begin: beginRect, end: endRect).animate(_controller);debugPrint('biggest=$_parentSize, beginRect=$beginRect, endRect=$endRect, status=${_controller.status}');_offset = _newOffset;_controller.reset();_controller.forward();return PositionedTransition(rect: rectAnimation, child: buildChild());}return Positioned(left: _offset.dx, top: _offset.dy, child: buildChild());}///上次点击时的坐标,与up事件后的坐标比对,如果实际上少于10像素,认为是主动的点击跳转行为(处理三星手机的异常)Offset? _lastPositionOffset;buildChild() {return Listener(onPointerDown: (event) {_lastPositionOffset = _newOffset;_isDragging = false;_isAnimating = false;},onPointerMove: (PointerMoveEvent pointerMoveEvent) {//更新位置if (pointerMoveEvent.delta.dx != 0 || pointerMoveEvent.delta.dy != 0) {_updatePosition(pointerMoveEvent);setState(() {_isDragging = true;_isAnimating = false;if (_lastPositionOffset != null) {double dx = _newOffset.dx - _lastPositionOffset!.dx;double dy = _newOffset.dy - _lastPositionOffset!.dy;//已经移动超过10像素,不管if ((dx > 10 || dx < -10) || (dy > 10 || dy < -10)) {_lastPositionOffset = null;}}});}},onPointerCancel: (event) {widget.onPressed();},onPointerUp: (PointerUpEvent pointerUpEvent) async {if (_isDragging) {_isDragging = false;if (_offset.dx < widget.padding) {_isAnimating = true;_newOffset = Offset(widget.padding, _offset.dy);} else if (_offset.dx >= (_parentSize.width - _childSize.width - widget.padding)) {_isAnimating = true;_newOffset = Offset(_parentSize.width - _childSize.width - widget.padding, _offset.dy);} else {if ((_offset.dx + _childSize.width / 2) > _parentSize.width / 2) {// 往右靠await Future.delayed(Duration(milliseconds: 100));_newOffset = Offset(_parentSize.width - _childSize.width - widget.padding, _offset.dy);_isAnimating = true;} else if ((_offset.dx + _childSize.width / 2) < _parentSize.width / 2) {// 往左靠await Future.delayed(Duration(milliseconds: 100));_newOffset = Offset(widget.padding, _offset.dy);_isAnimating = true;} else {_isAnimating = false;_newOffset = _offset;}}if (_offset.dy < kToolbarHeight) {_isAnimating = true;_newOffset = Offset(_newOffset.dx, kToolbarHeight);}widget.callBack(_newOffset);if (mounted) {setState(() {///x+y少于10像素,认为是普通的点击事件if (_lastPositionOffset != null) {double dx = _newOffset.dx - _lastPositionOffset!.dx;double dy = _newOffset.dy - _lastPositionOffset!.dy;if ((dx <= 10 && dx >= -10) && (dy <= 10 && dy >= -10)) {widget.onPressed();}}});}} else {widget.onPressed();}},child: Container(key: _childKey,child: widget.child,),);}
}

自定义一个可动画展开关闭的路由

class _CusOpenContainerRoute<T> extends ModalRoute<T> {_CusOpenContainerRoute({required this.closedColor,required this.openColor,required this.middleColor,required double closedElevation,required this.openElevation,required ShapeBorder closedShape,required this.openShape,required this.closedBuilder,required this.openBuilder,required this.hideableKey,required this.closedBuilderKey,required this.transitionDuration,required this.transitionType,required this.useRootNavigator,required RouteSettings? routeSettings,})  : _elevationTween = Tween<double>(begin: closedElevation,end: openElevation,),_shapeTween = ShapeBorderTween(begin: closedShape,end: openShape,),_colorTween = _getColorTween(transitionType: transitionType,closedColor: closedColor,openColor: openColor,middleColor: middleColor,),_closedOpacityTween = _getClosedOpacityTween(transitionType),_openOpacityTween = _getOpenOpacityTween(transitionType),super(settings: routeSettings);static _FlippableTweenSequence<Color?> _getColorTween({required ContainerTransitionType transitionType,required Color closedColor,required Color openColor,required Color middleColor,}) {switch (transitionType) {case ContainerTransitionType.fade:return _FlippableTweenSequence<Color?>(<TweenSequenceItem<Color?>>[TweenSequenceItem<Color>(tween: ConstantTween<Color>(closedColor),weight: 1 / 5,),TweenSequenceItem<Color?>(tween: ColorTween(begin: closedColor, end: openColor),weight: 1 / 5,),TweenSequenceItem<Color>(tween: ConstantTween<Color>(openColor),weight: 3 / 5,),],);case ContainerTransitionType.fadeThrough:return _FlippableTweenSequence<Color?>(<TweenSequenceItem<Color?>>[TweenSequenceItem<Color?>(tween: ColorTween(begin: closedColor, end: middleColor),weight: 1 / 5,),TweenSequenceItem<Color?>(tween: ColorTween(begin: middleColor, end: openColor),weight: 4 / 5,),],);}}static _FlippableTweenSequence<double> _getClosedOpacityTween(ContainerTransitionType transitionType) {switch (transitionType) {case ContainerTransitionType.fade:return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0),weight: 1 / 5,),TweenSequenceItem<double>(tween: ConstantTween<double>(0.0),weight: 4 / 5,),],);case ContainerTransitionType.fadeThrough:return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[TweenSequenceItem<double>(tween: Tween<double>(begin: 1.0, end: 0.0),weight: 1 / 5,),TweenSequenceItem<double>(tween: ConstantTween<double>(0.0),weight: 4 / 5,),]);}}static _FlippableTweenSequence<double> _getOpenOpacityTween(ContainerTransitionType transitionType) {switch (transitionType) {case ContainerTransitionType.fade:return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[TweenSequenceItem<double>(tween: ConstantTween<double>(0.0),weight: 1 / 5,),TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0),weight: 1 / 5,),TweenSequenceItem<double>(tween: ConstantTween<double>(1.0),weight: 3 / 5,),],);case ContainerTransitionType.fadeThrough:return _FlippableTweenSequence<double>(<TweenSequenceItem<double>>[TweenSequenceItem<double>(tween: ConstantTween<double>(0.0),weight: 1 / 5,),TweenSequenceItem<double>(tween: Tween<double>(begin: 0.0, end: 1.0),weight: 4 / 5,),],);}}final Color closedColor;final Color openColor;final Color middleColor;final double openElevation;final ShapeBorder openShape;final CloseContainerBuilder closedBuilder;final CusOpenContainerBuilder<T> openBuilder;// See [_CusOpenContainerState._hideableKey].final GlobalKey<_HideableState> hideableKey;// See [_CusOpenContainerState._closedBuilderKey].final GlobalKey closedBuilderKey;@overridefinal Duration transitionDuration;final ContainerTransitionType transitionType;final bool useRootNavigator;final Tween<double> _elevationTween;final ShapeBorderTween _shapeTween;final _FlippableTweenSequence<double> _closedOpacityTween;final _FlippableTweenSequence<double> _openOpacityTween;final _FlippableTweenSequence<Color?> _colorTween;static final TweenSequence<Color?> _scrimFadeInTween = TweenSequence<Color?>(<TweenSequenceItem<Color?>>[TweenSequenceItem<Color?>(tween: ColorTween(begin: Colors.transparent, end: Colors.transparent),weight: 1 / 5,),TweenSequenceItem<Color>(tween: ConstantTween<Color>(Colors.transparent),weight: 4 / 5,),],);static final Tween<Color?> _scrimFadeOutTween = ColorTween(begin: Colors.transparent,end: Colors.transparent,);// Key used for the widget returned by [CusOpenContainer.openBuilder] to keep// its state when the shape of the widget tree is changed at the end of the// animation to remove all the craft that was necessary to make the animation// work.final GlobalKey _openBuilderKey = GlobalKey();// Defines the position and the size of the (opening) [CusOpenContainer] within// the bounds of the enclosing [Navigator].final RectTween _rectTween = RectTween();AnimationStatus? _lastAnimationStatus;AnimationStatus? _currentAnimationStatus;@overrideTickerFuture didPush() {_takeMeasurements(navigatorContext: hideableKey.currentContext!);animation!.addStatusListener((AnimationStatus status) {_lastAnimationStatus = _currentAnimationStatus;_currentAnimationStatus = status;switch (status) {case AnimationStatus.dismissed:_toggleHideable(hide: false);break;case AnimationStatus.completed:_toggleHideable(hide: true);break;case AnimationStatus.forward:case AnimationStatus.reverse:break;}});return super.didPush();}@overridebool didPop(T? result) {_takeMeasurements(navigatorContext: subtreeContext!,delayForSourceRoute: true,);return super.didPop(result);}@overridevoid dispose() {if (hideableKey.currentState?.isVisible == false) {// This route may be disposed without dismissing its animation if it is// removed by the navigator.SchedulerBinding.instance.addPostFrameCallback((Duration d) => _toggleHideable(hide: false));}super.dispose();}void _toggleHideable({required bool hide}) {if (hideableKey.currentState != null) {hideableKey.currentState!..placeholderSize = null..isVisible = !hide;}}void _takeMeasurements({required BuildContext navigatorContext,bool delayForSourceRoute = false,}) {final RenderBox navigator = Navigator.of(navigatorContext,rootNavigator: useRootNavigator,).context.findRenderObject()! as RenderBox;final Size navSize = _getSize(navigator);_rectTween.end = Offset.zero & navSize;void takeMeasurementsInSourceRoute([Duration? _]) {if (!navigator.attached || hideableKey.currentContext == null) {return;}_rectTween.begin = _getRect(hideableKey, navigator);hideableKey.currentState!.placeholderSize = _rectTween.begin!.size;}if (delayForSourceRoute) {SchedulerBinding.instance.addPostFrameCallback(takeMeasurementsInSourceRoute);} else {takeMeasurementsInSourceRoute();}}Size _getSize(RenderBox render) {assert(render.hasSize);return render.size;}// Returns the bounds of the [RenderObject] identified by `key` in the// coordinate system of `ancestor`.Rect _getRect(GlobalKey key, RenderBox ancestor) {assert(key.currentContext != null);assert(ancestor.hasSize);final RenderBox render = key.currentContext!.findRenderObject()! as RenderBox;assert(render.hasSize);return MatrixUtils.transformRect(render.getTransformTo(ancestor),Offset.zero & render.size,);}bool get _transitionWasInterrupted {bool wasInProgress = false;bool isInProgress = false;switch (_currentAnimationStatus) {case AnimationStatus.completed:case AnimationStatus.dismissed:isInProgress = false;break;case AnimationStatus.forward:case AnimationStatus.reverse:isInProgress = true;break;case null:break;}switch (_lastAnimationStatus) {case AnimationStatus.completed:case AnimationStatus.dismissed:wasInProgress = false;break;case AnimationStatus.forward:case AnimationStatus.reverse:wasInProgress = true;break;case null:break;}return wasInProgress && isInProgress;}void closeContainer({T? returnValue}) {Navigator.of(subtreeContext!).pop(returnValue);}@overrideWidget buildPage(BuildContext context,Animation<double> animation,Animation<double> secondaryAnimation,) {return Align(alignment: Alignment.topLeft,child: AnimatedBuilder(animation: animation,builder: (BuildContext context, Widget? child) {if (animation.isCompleted) {return SizedBox.expand(child: Material(color: openColor,elevation: openElevation,shape: openShape,type: MaterialType.transparency,child: Builder(key: _openBuilderKey,builder: (BuildContext context) {return openBuilder(context, closeContainer);},),),);}final Animation<double> curvedAnimation = CurvedAnimation(parent: animation,curve: Curves.fastOutSlowIn,reverseCurve: _transitionWasInterrupted ? null : Curves.fastOutSlowIn.flipped,);TweenSequence<Color?>? colorTween;TweenSequence<double>? closedOpacityTween, openOpacityTween;Animatable<Color?>? scrimTween;switch (animation.status) {case AnimationStatus.dismissed:case AnimationStatus.forward:closedOpacityTween = _closedOpacityTween;openOpacityTween = _openOpacityTween;colorTween = _colorTween;scrimTween = _scrimFadeInTween;break;case AnimationStatus.reverse:if (_transitionWasInterrupted) {closedOpacityTween = _closedOpacityTween;openOpacityTween = _openOpacityTween;colorTween = _colorTween;scrimTween = _scrimFadeInTween;break;}closedOpacityTween = _closedOpacityTween.flipped;openOpacityTween = _openOpacityTween.flipped;colorTween = _colorTween.flipped;scrimTween = _scrimFadeOutTween;break;case AnimationStatus.completed:assert(false); // Unreachable.}assert(colorTween != null);assert(closedOpacityTween != null);assert(openOpacityTween != null);assert(scrimTween != null);final Rect rect = _rectTween.evaluate(curvedAnimation)!;return SizedBox.expand(child: Container(color: scrimTween!.evaluate(curvedAnimation),child: Align(alignment: Alignment.topLeft,child: Transform.translate(offset: Offset(rect.left, rect.top),child: SizedBox(width: rect.width,height: rect.height,child: Material(clipBehavior: Clip.antiAlias,animationDuration: Duration.zero,type: MaterialType.transparency,color: colorTween!.evaluate(animation),shape: _shapeTween.evaluate(curvedAnimation),elevation: _elevationTween.evaluate(curvedAnimation),child: Stack(fit: StackFit.passthrough,children: <Widget>[// Closed child fading out.FittedBox(fit: BoxFit.fitWidth,alignment: Alignment.center,child: SizedBox(width: _rectTween.begin!.width,height: _rectTween.begin!.height,child: (hideableKey.currentState?.isInTree ?? false)? null: FadeTransition(opacity: closedOpacityTween!.animate(animation),child: Builder(key: closedBuilderKey,builder: (BuildContext context) {// Use dummy "open container" callback// since we are in the process of opening.return closedBuilder(context, () {});},),),),),// Open child fading in.FittedBox(fit: BoxFit.fitWidth,alignment: Alignment.topLeft,child: SizedBox(width: _rectTween.end!.width,height: _rectTween.end!.height,child: FadeTransition(opacity: openOpacityTween!.animate(animation),child: Builder(key: _openBuilderKey,builder: (BuildContext context) {return openBuilder(context, closeContainer);},),),),),],),),),),),),);},),);}@overridebool get maintainState => true;@overrideColor? get barrierColor => null;@overridebool get opaque => false;@overridebool get barrierDismissible => false;@overrideString? get barrierLabel => null;
}

使用->创建OverlayEntry

class MyHomePage extends StatefulWidget {const MyHomePage({super.key, required this.title});final String title;@overrideState<MyHomePage> createState() => _MyHomePageState();
}const double BALL_SIZE = 104;class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {int _counter = 0;var workBallOffset = Offset(ScreenUtil().screenWidth - 28.w - BALL_SIZE.w,ScreenUtil().screenHeight - BALL_SIZE.w - 228.w).obs;OverlayEntry? _windowBall;@overridevoid initState() {// TODO: implement initStatesuper.initState();WidgetsBinding.instance.addObserver(this);WidgetsBinding.instance.addPostFrameCallback((Value) {insertWorkBall();});}@overridevoid dispose() {WidgetsBinding.instance.removeObserver(this);super.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(backgroundColor: Theme.of(context).colorScheme.inversePrimary,title: Text(widget.title),),body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: <Widget>[const Text('You have pushed the button this many times:',),Text('$_counter',style: Theme.of(context).textTheme.headlineMedium,),],),),);}OverlayEntry buildWorkbenchEntry() {return OverlayEntry(builder: (context) {VoidCallback? open;return Stack(children: [DraggableFloatingActionButton(child: CusOpenContainer(transitionType: ContainerTransitionType.fade,openBuilder: (BuildContext context, VoidCallback _) {return WindowBall();},closedElevation: 0,closedShape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular((BALL_SIZE / 2)),),),closedColor: Theme.of(context).colorScheme.secondary,openColor: Colors.transparent,closedBuilder:(BuildContext context, VoidCallback openContainer) {open = openContainer;return BallBtn();},),callBack: (offset) {debugPrint('callback -->${offset}');workBallOffset.value = offset;},initialOffset: workBallOffset.value,// parentKey: _parentKey,parentContext: context,childSize: Size(BALL_SIZE.w, BALL_SIZE.w),onPressed: () {open?.call();},),// ),],);});}insertWorkBall() {if (_windowBall == null) {_windowBall = buildWorkbenchEntry();Overlay.of(context).insert(_windowBall!);}}// 移除工作台悬浮窗removeWorkBall() {if (_windowBall != null) {_windowBall?.remove();_windowBall = null;}}
}

demo

完整代码查看demo

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词