Instantly share code, notes, and snippets.
Last active
October 4, 2025 21:00
-
Star
2
(2)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save sbis04/e51184ef81d67565b17d4e38541c9933 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:ui' as ui; | |
| import 'package:flutter/material.dart'; | |
| void main() { | |
| runApp(const MyApp()); | |
| } | |
| class MyApp extends StatelessWidget { | |
| const MyApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| title: 'Stretchy Menu Demo', | |
| debugShowCheckedModeBanner: false, | |
| home: const StretchyMenuPage(), | |
| ); | |
| } | |
| } | |
| class StretchyMenuPage extends StatefulWidget { | |
| const StretchyMenuPage({super.key}); | |
| @override | |
| State<StretchyMenuPage> createState() => _StretchyMenuPageState(); | |
| } | |
| class _StretchyMenuPageState extends State<StretchyMenuPage> | |
| with TickerProviderStateMixin { | |
| late final AnimationController _animationController; | |
| late final Animation<double> _animation; // Curved (elasticOut) | |
| // Container translation offsets (dragging the whole card) | |
| double _offsetX = 0.0; | |
| double _offsetY = 0.0; | |
| bool _isDragging = false; | |
| // Tracks the local finger position within the container to drive proximity sizing | |
| Offset? _dragLocalPos; // starts as null so no initial proximity effect | |
| // Store animation starts to avoid adding listeners repeatedly | |
| double _animStartX = 0.0; | |
| double _animStartY = 0.0; | |
| // Config | |
| final double _containerWidth = 300.0; | |
| final double _containerHeight = 400.0; | |
| // Grid config (9x9) | |
| static const int _rows = 9; | |
| static const int _cols = 9; | |
| final double _gridPadding = 20.0; // keeps dots away from edges | |
| // Dot sizing behavior | |
| final double _baseDot = 4.0; // default tiny dots | |
| final double _maxGrow = 12.0; // additional size when near the drag point | |
| final double _influenceRadius = 120.0; // px distance for full effect falloff | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _animationController = AnimationController( | |
| duration: const Duration(milliseconds: 200), | |
| vsync: this, | |
| ); | |
| _animation = CurvedAnimation( | |
| parent: _animationController, | |
| curve: Curves.easeInOut, | |
| ); | |
| // Single listener to drive the snap-back of the container and the fade-out of the proximity effect | |
| _animationController.addListener(() { | |
| if (!_isDragging) { | |
| setState(() { | |
| _offsetX = _animStartX * (1 - _animation.value); | |
| _offsetY = _animStartY * (1 - _animation.value); | |
| }); | |
| } | |
| }); | |
| } | |
| @override | |
| void dispose() { | |
| _animationController.dispose(); | |
| super.dispose(); | |
| } | |
| void _onPanUpdate(DragUpdateDetails details) { | |
| setState(() { | |
| _isDragging = true; | |
| // Track finger position within the container (clamped to its bounds) | |
| final dx = details.localPosition.dx.clamp(0.0, _containerWidth); | |
| final dy = details.localPosition.dy.clamp(0.0, _containerHeight); | |
| _dragLocalPos = Offset(dx, dy); | |
| // Calculate stretch direction based on drag position relative to center | |
| final centerX = _containerWidth / 2; | |
| final centerY = _containerHeight / 2; | |
| // Calculate direction and intensity (how far from center) | |
| final directionX = (dx - centerX) / centerX; // -1 to 1 | |
| final directionY = (dy - centerY) / centerY; // -1 to 1 | |
| // Apply stretchy movement (container moves in direction of drag) | |
| _offsetX = (directionX * 20).clamp(-20.0, 20.0); | |
| _offsetY = (directionY * 20).clamp(-20.0, 20.0); | |
| }); | |
| } | |
| void _onPanEnd(DragEndDetails details) { | |
| setState(() { | |
| _isDragging = false; | |
| _animStartX = _offsetX; | |
| _animStartY = _offsetY; | |
| }); | |
| _animationController | |
| ..reset() | |
| ..forward(); | |
| } | |
| String _getGridCoordinates(Offset localPos) { | |
| // Calculate which grid cell the position corresponds to | |
| final usableW = _containerWidth - 2 * _gridPadding; | |
| final usableH = (_containerHeight - 80) - 2 * _gridPadding; // Account for text area | |
| final stepX = _cols > 1 ? usableW / (_cols - 1) : usableW; | |
| final stepY = _rows > 1 ? usableH / (_rows - 1) : usableH; | |
| // Convert local position to grid position (accounting for padding) | |
| final gridX = ((localPos.dx - _gridPadding) / stepX).clamp(0, _cols - 1).round(); | |
| final gridY = ((localPos.dy - _gridPadding) / stepY).clamp(0, _rows - 1).round(); | |
| return 'X: $gridX / Y: $gridY'; | |
| } | |
| Widget _buildDotGrid({required double width, required double height}) { | |
| final List<Widget> dots = []; | |
| final usableW = width - 2 * _gridPadding; | |
| final usableH = height - 2 * _gridPadding; | |
| final stepX = _cols > 1 ? usableW / (_cols - 1) : usableW; | |
| final stepY = _rows > 1 ? usableH / (_rows - 1) : usableH; | |
| // Proximity strength: 1 when dragging, smoothly fades to 0 as the card snaps back | |
| final double proximityStrength = _isDragging ? 1.0 : (1.0 - _animation.value); | |
| for (int r = 0; r < _rows; r++) { | |
| for (int c = 0; c < _cols; c++) { | |
| final double x = _gridPadding + c * stepX; | |
| final double y = _gridPadding + r * stepY; | |
| // Calculate proximity effect only if we have a drag position | |
| double eased = 0.0; | |
| if (_dragLocalPos != null) { | |
| final double dist = (Offset(x, y) - _dragLocalPos!).distance; | |
| final double tRaw = 1.0 - (dist / _influenceRadius); | |
| final double t = tRaw.clamp(0.0, 1.0); | |
| eased = Curves.easeOut.transform(t); | |
| } | |
| final double grow = _maxGrow * eased * proximityStrength; | |
| final double dotSize = _baseDot + grow; | |
| // Subtle brightness boost near the finger | |
| final double alpha = (0.45 + 0.55 * eased * proximityStrength).clamp(0.0, 1.0); | |
| dots.add( | |
| Positioned( | |
| left: x - dotSize / 2, | |
| top: y - dotSize / 2, | |
| child: Container( | |
| width: dotSize, | |
| height: dotSize, | |
| decoration: BoxDecoration( | |
| color: Colors.white.withValues(alpha: alpha), | |
| shape: BoxShape.circle, | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| return Stack(children: dots); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final size = MediaQuery.of(context).size; | |
| return Scaffold( | |
| body: Container( | |
| decoration: const BoxDecoration( | |
| image: DecorationImage( | |
| image: NetworkImage( | |
| 'https://images.unsplash.com/photo-1505832018823-50331d70d237?q=80&w=1980', | |
| ), | |
| fit: BoxFit.cover, | |
| ), | |
| ), | |
| child: Stack( | |
| children: [ | |
| AnimatedPositioned( | |
| duration: _isDragging | |
| ? Duration.zero | |
| : const Duration(milliseconds: 600), | |
| curve: Curves.elasticOut, | |
| left: size.width / 2 - _containerWidth / 2 + _offsetX, | |
| top: size.height / 2 - _containerHeight / 2 + _offsetY, | |
| child: Transform( | |
| // Adjust transform origin based on drag direction | |
| alignment: Alignment( | |
| _offsetX > 0 ? -0.5 : 0.5, // When dragging right, anchor left; when dragging left, anchor right | |
| _offsetY > 0 ? -0.5 : 0.5, // When dragging down, anchor top; when dragging up, anchor bottom | |
| ), | |
| transform: Matrix4.identity() | |
| ..scale( | |
| 1.0 + (_offsetX.abs() / 20 * 0.15), // Horizontal stretch in drag direction | |
| 1.0 + (_offsetY.abs() / 20 * 0.15), // Vertical stretch in drag direction | |
| ), | |
| child: GestureDetector( | |
| onPanUpdate: _onPanUpdate, | |
| onPanEnd: _onPanEnd, | |
| child: Container( | |
| width: _containerWidth, | |
| height: _containerHeight, | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(40), | |
| border: Border.all( | |
| color: Colors.white.withValues(alpha: 0.2), | |
| width: 1.5, | |
| ), | |
| boxShadow: [ | |
| BoxShadow( | |
| color: Colors.black.withValues(alpha: 0.12), | |
| blurRadius: 24, | |
| offset: const Offset(0, 12), | |
| ), | |
| ], | |
| ), | |
| child: ClipRRect( | |
| borderRadius: BorderRadius.circular(40), | |
| child: Stack( | |
| fit: StackFit.expand, | |
| children: [ | |
| // Localized blur behind the container | |
| BackdropFilter( | |
| filter: | |
| ui.ImageFilter.blur(sigmaX: 12.0, sigmaY: 12.0), | |
| child: const SizedBox.expand(), | |
| ), | |
| // Frosted overlay tint | |
| Container( | |
| color: Colors.black.withValues(alpha: 0.1), | |
| ), | |
| // Add margin around the entire container content | |
| Padding( | |
| padding: const EdgeInsets.all(16), | |
| child: Column( | |
| children: [ | |
| // Grid area (takes most of the space) | |
| Expanded( | |
| child: LayoutBuilder( | |
| builder: (context, constraints) => _buildDotGrid( | |
| width: constraints.maxWidth, | |
| height: constraints.maxHeight, | |
| ), | |
| ), | |
| ), | |
| // Divider | |
| Container( | |
| margin: const EdgeInsets.symmetric(horizontal: 20), | |
| height: 1, | |
| decoration: BoxDecoration( | |
| gradient: LinearGradient( | |
| colors: [ | |
| Colors.transparent, | |
| Colors.white.withValues(alpha: 0.3), | |
| Colors.transparent, | |
| ], | |
| ), | |
| ), | |
| ), | |
| // Text row at bottom | |
| Container( | |
| height: 40, | |
| padding: const EdgeInsets.only(top: 8, left: 16, right: 16,), | |
| child: Row( | |
| mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
| children: [ | |
| Text( | |
| 'FOCUS', | |
| style: TextStyle( | |
| color: Colors.white.withValues(alpha: 0.85), | |
| fontSize: 16, | |
| fontWeight: FontWeight.w600, | |
| letterSpacing: 2.0, | |
| ), | |
| ), | |
| Text( | |
| _dragLocalPos != null | |
| ? _getGridCoordinates(_dragLocalPos!) | |
| : 'X: 0 / Y: 0', | |
| style: TextStyle( | |
| color: Colors.white.withValues(alpha: 0.8), | |
| fontSize: 16, | |
| fontWeight: FontWeight.w500, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment