import "dart:math" as math; import "package:flutter/material.dart"; class SidebarSwiper extends StatefulWidget { const SidebarSwiper({ required this.sidebar, required this.child, this.maxSidebarWidth = 360, this.sidebarWidthFactor = 0.92, this.edgeDragWidth = 44, this.animationDuration = const Duration(milliseconds: 220), super.key, }); final Widget sidebar; final Widget child; final double maxSidebarWidth; final double sidebarWidthFactor; final double edgeDragWidth; final Duration animationDuration; @override State createState() => _SidebarSwiperState(); } class _SidebarSwiperState extends State { static const double _closedExtraOffset = 12; double _progress = 0; // 0 = closed, 1 = fully open bool _isDragging = false; bool _canDrag = false; double _dragStartGlobalX = 0; double _dragStartProgress = 0; void _setOpen(bool value) { setState(() { _isDragging = false; _progress = value ? 1 : 0; }); } void _handleDragStart(DragStartDetails details, double sidebarWidth) { final isOpen = _progress > 0.001; final fromEdge = details.globalPosition.dx <= widget.edgeDragWidth; final fromSidebarZone = details.globalPosition.dx <= sidebarWidth; _canDrag = fromEdge || (isOpen && fromSidebarZone); if (!_canDrag) return; setState(() { _isDragging = true; _dragStartGlobalX = details.globalPosition.dx; _dragStartProgress = _progress; }); } void _handleDragUpdate(DragUpdateDetails details, double sidebarWidth) { if (!_canDrag || !_isDragging) return; if (sidebarWidth <= 0) return; final movedX = details.globalPosition.dx - _dragStartGlobalX; setState(() { _progress = (_dragStartProgress + (movedX / sidebarWidth)).clamp(0, 1); }); } void _handleDragEnd(DragEndDetails details) { if (!_canDrag) return; _canDrag = false; final velocity = details.primaryVelocity ?? 0; final shouldOpen = velocity > 250 ? true : (velocity < -250 ? false : _progress >= 0.35); _setOpen(shouldOpen); } @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final sidebarWidth = math.min( widget.maxSidebarWidth, screenWidth * widget.sidebarWidthFactor, ); final leftOffset = -(sidebarWidth + _closedExtraOffset) + ((sidebarWidth + _closedExtraOffset) * _progress); final showScrim = _progress > 0; return Stack( children: [ widget.child, Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) => _handleDragStart(details, sidebarWidth), onHorizontalDragUpdate: (details) => _handleDragUpdate(details, sidebarWidth), onHorizontalDragEnd: _handleDragEnd, child: const SizedBox.expand(), ), ), if (showScrim) Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _setOpen(false), child: Container( color: Colors.black.withValues(alpha: 0.45 * _progress), ), ), ), AnimatedPositioned( duration: _isDragging ? Duration.zero : widget.animationDuration, curve: Curves.easeOutCubic, left: leftOffset, top: 0, bottom: 0, width: sidebarWidth, child: Material( color: Theme.of(context).scaffoldBackgroundColor, child: widget.sidebar, ), ), ], ); } }