Skip to content

Instantly share code, notes, and snippets.

@sbis04
Last active October 4, 2025 21:00
Show Gist options
  • Save sbis04/e51184ef81d67565b17d4e38541c9933 to your computer and use it in GitHub Desktop.
Save sbis04/e51184ef81d67565b17d4e38541c9933 to your computer and use it in GitHub Desktop.
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