343 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			Dart
		
	
	
	
			
		
		
	
	
			343 lines
		
	
	
		
			9.9 KiB
		
	
	
	
		
			Dart
		
	
	
	
| import 'package:flutter/material.dart';
 | |
| 
 | |
| import 'package:fluffychat/config/themes.dart';
 | |
| import 'dismiss_keyboard.dart';
 | |
| 
 | |
| class PIPView extends StatefulWidget {
 | |
|   final PIPViewCorner initialCorner;
 | |
|   final double? floatingWidth;
 | |
|   final double? floatingHeight;
 | |
|   final bool avoidKeyboard;
 | |
| 
 | |
|   final Widget Function(
 | |
|     BuildContext context,
 | |
|     bool isFloating,
 | |
|   ) builder;
 | |
| 
 | |
|   const PIPView({
 | |
|     super.key,
 | |
|     required this.builder,
 | |
|     this.initialCorner = PIPViewCorner.topRight,
 | |
|     this.floatingWidth,
 | |
|     this.floatingHeight,
 | |
|     this.avoidKeyboard = true,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   PIPViewState createState() => PIPViewState();
 | |
| 
 | |
|   static PIPViewState? of(BuildContext context) {
 | |
|     return context.findAncestorStateOfType<PIPViewState>();
 | |
|   }
 | |
| }
 | |
| 
 | |
| class PIPViewState extends State<PIPView> with TickerProviderStateMixin {
 | |
|   late AnimationController _toggleFloatingAnimationController;
 | |
|   late AnimationController _dragAnimationController;
 | |
|   late PIPViewCorner _corner;
 | |
|   Offset _dragOffset = Offset.zero;
 | |
|   bool _isDragging = false;
 | |
|   bool _floating = false;
 | |
|   Map<PIPViewCorner, Offset> _offsets = {};
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     _corner = widget.initialCorner;
 | |
|     _toggleFloatingAnimationController = AnimationController(
 | |
|       duration: FluffyThemes.animationDuration,
 | |
|       vsync: this,
 | |
|     );
 | |
|     _dragAnimationController = AnimationController(
 | |
|       duration: FluffyThemes.animationDuration,
 | |
|       vsync: this,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   void _updateCornersOffsets({
 | |
|     required Size spaceSize,
 | |
|     required Size widgetSize,
 | |
|     required EdgeInsets windowPadding,
 | |
|   }) {
 | |
|     _offsets = _calculateOffsets(
 | |
|       spaceSize: spaceSize,
 | |
|       widgetSize: widgetSize,
 | |
|       windowPadding: windowPadding,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   bool _isAnimating() {
 | |
|     return _toggleFloatingAnimationController.isAnimating ||
 | |
|         _dragAnimationController.isAnimating;
 | |
|   }
 | |
| 
 | |
|   void setFloating(bool floating) {
 | |
|     if (_isAnimating()) return;
 | |
|     dismissKeyboard(context);
 | |
|     setState(() {
 | |
|       _floating = floating;
 | |
|     });
 | |
|     _toggleFloatingAnimationController.forward();
 | |
|   }
 | |
| 
 | |
|   void stopFloating() {
 | |
|     if (_isAnimating()) return;
 | |
|     dismissKeyboard(context);
 | |
|     _toggleFloatingAnimationController.reverse().whenCompleteOrCancel(() {
 | |
|       if (mounted) {
 | |
|         setState(() {
 | |
|           _floating = false;
 | |
|         });
 | |
|       }
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   void _onPanUpdate(DragUpdateDetails details) {
 | |
|     if (!_isDragging) return;
 | |
|     setState(() {
 | |
|       _dragOffset = _dragOffset.translate(
 | |
|         details.delta.dx,
 | |
|         details.delta.dy,
 | |
|       );
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   void _onPanCancel() {
 | |
|     if (!_isDragging) return;
 | |
|     setState(() {
 | |
|       _dragAnimationController.value = 0;
 | |
|       _dragOffset = Offset.zero;
 | |
|       _isDragging = false;
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   void _onPanEnd(_) {
 | |
|     if (!_isDragging) return;
 | |
| 
 | |
|     final nearestCorner = _calculateNearestCorner(
 | |
|       offset: _dragOffset,
 | |
|       offsets: _offsets,
 | |
|     );
 | |
|     setState(() {
 | |
|       _corner = nearestCorner;
 | |
|       _isDragging = false;
 | |
|     });
 | |
|     _dragAnimationController.forward().whenCompleteOrCancel(() {
 | |
|       _dragAnimationController.value = 0;
 | |
|       _dragOffset = Offset.zero;
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   void _onPanStart(_) {
 | |
|     if (_isAnimating()) return;
 | |
|     setState(() {
 | |
|       _dragOffset = _offsets[_corner]!;
 | |
|       _isDragging = true;
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     var windowPadding = MediaQuery.paddingOf(context);
 | |
|     if (widget.avoidKeyboard) {
 | |
|       windowPadding += MediaQuery.viewInsetsOf(context);
 | |
|     }
 | |
|     final isFloating = _floating;
 | |
| 
 | |
|     return LayoutBuilder(
 | |
|       builder: (context, constraints) {
 | |
|         final width = constraints.maxWidth;
 | |
|         final height = constraints.maxHeight;
 | |
|         var floatingWidth = widget.floatingWidth;
 | |
|         var floatingHeight = widget.floatingHeight;
 | |
|         if (floatingWidth == null && floatingHeight != null) {
 | |
|           floatingWidth = width / height * floatingHeight;
 | |
|         }
 | |
|         floatingWidth ??= 100.0;
 | |
|         floatingHeight ??= height / width * floatingWidth;
 | |
| 
 | |
|         final floatingWidgetSize = Size(floatingWidth, floatingHeight);
 | |
|         final fullWidgetSize = Size(width, height);
 | |
| 
 | |
|         _updateCornersOffsets(
 | |
|           spaceSize: fullWidgetSize,
 | |
|           widgetSize: floatingWidgetSize,
 | |
|           windowPadding: windowPadding,
 | |
|         );
 | |
| 
 | |
|         final calculatedOffset = _offsets[_corner];
 | |
| 
 | |
|         // BoxFit.cover
 | |
|         final widthRatio = floatingWidth / width;
 | |
|         final heightRatio = floatingHeight / height;
 | |
|         final scaledDownScale = widthRatio > heightRatio
 | |
|             ? floatingWidgetSize.width / fullWidgetSize.width
 | |
|             : floatingWidgetSize.height / fullWidgetSize.height;
 | |
| 
 | |
|         return Stack(
 | |
|           children: <Widget>[
 | |
|             AnimatedBuilder(
 | |
|               animation: Listenable.merge([
 | |
|                 _toggleFloatingAnimationController,
 | |
|                 _dragAnimationController,
 | |
|               ]),
 | |
|               builder: (context, child) {
 | |
|                 final animationCurve = CurveTween(
 | |
|                   curve: Curves.easeInOutQuad,
 | |
|                 );
 | |
|                 final dragAnimationValue = animationCurve.transform(
 | |
|                   _dragAnimationController.value,
 | |
|                 );
 | |
|                 final toggleFloatingAnimationValue = animationCurve.transform(
 | |
|                   _toggleFloatingAnimationController.value,
 | |
|                 );
 | |
| 
 | |
|                 final floatingOffset = _isDragging
 | |
|                     ? _dragOffset
 | |
|                     : Tween<Offset>(
 | |
|                         begin: _dragOffset,
 | |
|                         end: calculatedOffset,
 | |
|                       ).transform(
 | |
|                         _dragAnimationController.isAnimating
 | |
|                             ? dragAnimationValue
 | |
|                             : toggleFloatingAnimationValue,
 | |
|                       );
 | |
|                 final borderRadius = Tween<double>(
 | |
|                   begin: 0,
 | |
|                   end: 10,
 | |
|                 ).transform(toggleFloatingAnimationValue);
 | |
|                 final width = Tween<double>(
 | |
|                   begin: fullWidgetSize.width,
 | |
|                   end: floatingWidgetSize.width,
 | |
|                 ).transform(toggleFloatingAnimationValue);
 | |
|                 final height = Tween<double>(
 | |
|                   begin: fullWidgetSize.height,
 | |
|                   end: floatingWidgetSize.height,
 | |
|                 ).transform(toggleFloatingAnimationValue);
 | |
|                 final scale = Tween<double>(
 | |
|                   begin: 1,
 | |
|                   end: scaledDownScale,
 | |
|                 ).transform(toggleFloatingAnimationValue);
 | |
|                 return Positioned(
 | |
|                   left: floatingOffset.dx,
 | |
|                   top: floatingOffset.dy,
 | |
|                   child: GestureDetector(
 | |
|                     onPanStart: isFloating ? _onPanStart : null,
 | |
|                     onPanUpdate: isFloating ? _onPanUpdate : null,
 | |
|                     onPanCancel: isFloating ? _onPanCancel : null,
 | |
|                     onPanEnd: isFloating ? _onPanEnd : null,
 | |
|                     onTap: isFloating ? stopFloating : null,
 | |
|                     child: Material(
 | |
|                       elevation: 10,
 | |
|                       borderRadius: BorderRadius.circular(borderRadius),
 | |
|                       child: Container(
 | |
|                         clipBehavior: Clip.antiAlias,
 | |
|                         decoration: BoxDecoration(
 | |
|                           color: Colors.transparent,
 | |
|                           borderRadius: BorderRadius.circular(borderRadius),
 | |
|                         ),
 | |
|                         width: width,
 | |
|                         height: height,
 | |
|                         child: Transform.scale(
 | |
|                           scale: scale,
 | |
|                           child: OverflowBox(
 | |
|                             maxHeight: fullWidgetSize.height,
 | |
|                             maxWidth: fullWidgetSize.width,
 | |
|                             child: IgnorePointer(
 | |
|                               ignoring: isFloating,
 | |
|                               child: child,
 | |
|                             ),
 | |
|                           ),
 | |
|                         ),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ),
 | |
|                 );
 | |
|               },
 | |
|               child: Builder(
 | |
|                 builder: (context) => widget.builder(context, isFloating),
 | |
|               ),
 | |
|             ),
 | |
|           ],
 | |
|         );
 | |
|       },
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| enum PIPViewCorner {
 | |
|   topLeft,
 | |
|   topRight,
 | |
|   bottomLeft,
 | |
|   bottomRight,
 | |
| }
 | |
| 
 | |
| class _CornerDistance {
 | |
|   final PIPViewCorner corner;
 | |
|   final double distance;
 | |
| 
 | |
|   _CornerDistance({
 | |
|     required this.corner,
 | |
|     required this.distance,
 | |
|   });
 | |
| }
 | |
| 
 | |
| PIPViewCorner _calculateNearestCorner({
 | |
|   required Offset offset,
 | |
|   required Map<PIPViewCorner, Offset> offsets,
 | |
| }) {
 | |
|   _CornerDistance calculateDistance(PIPViewCorner corner) {
 | |
|     final distance = offsets[corner]!
 | |
|         .translate(
 | |
|           -offset.dx,
 | |
|           -offset.dy,
 | |
|         )
 | |
|         .distanceSquared;
 | |
|     return _CornerDistance(
 | |
|       corner: corner,
 | |
|       distance: distance,
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   final distances = PIPViewCorner.values.map(calculateDistance).toList();
 | |
| 
 | |
|   distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance));
 | |
| 
 | |
|   return distances.first.corner;
 | |
| }
 | |
| 
 | |
| Map<PIPViewCorner, Offset> _calculateOffsets({
 | |
|   required Size spaceSize,
 | |
|   required Size widgetSize,
 | |
|   required EdgeInsets windowPadding,
 | |
| }) {
 | |
|   Offset getOffsetForCorner(PIPViewCorner corner) {
 | |
|     const spacing = 16;
 | |
|     final left = spacing + windowPadding.left;
 | |
|     final top = spacing + windowPadding.top;
 | |
|     final right =
 | |
|         spaceSize.width - widgetSize.width - windowPadding.right - spacing;
 | |
|     final bottom =
 | |
|         spaceSize.height - widgetSize.height - windowPadding.bottom - spacing;
 | |
| 
 | |
|     switch (corner) {
 | |
|       case PIPViewCorner.topLeft:
 | |
|         return Offset(left, top);
 | |
|       case PIPViewCorner.topRight:
 | |
|         return Offset(right, top);
 | |
|       case PIPViewCorner.bottomLeft:
 | |
|         return Offset(left, bottom);
 | |
|       case PIPViewCorner.bottomRight:
 | |
|         return Offset(right, bottom);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const corners = PIPViewCorner.values;
 | |
|   final offsets = <PIPViewCorner, Offset>{};
 | |
|   for (final corner in corners) {
 | |
|     offsets[corner] = getOffsetForCorner(corner);
 | |
|   }
 | |
| 
 | |
|   return offsets;
 | |
| }
 |