fix: keep dock drag springs live and add elastic leading-edge pull

This commit is contained in:
david1465833828
2026-10-28 11:57:44 +08:00
parent 6604f31d98
commit c5cfbced5c
9 changed files with 261 additions and 42 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
const appVersion = '0.7.8';
const appBuildNumber = 30;
const appBuildNumber = 31;
const appFilingNumber = '蜀ICP备2025169276号-5A';
+155 -31
View File
@@ -3,6 +3,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/scheduler.dart';
import 'glass_refraction.dart';
@@ -38,19 +39,91 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
late final AnimationController _sway = AnimationController.unbounded(
vsync: this,
);
late final AnimationController _ripple = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 620),
);
void _wakeRipple() {
if (!_reduceMotion && !_ripple.isAnimating) _ripple.forward(from: 0);
}
double _swayTarget = 0, _swayVelocity = 0, _dragTarget = 0, _dragVelocity = 0;
Duration? _elasticLastTick;
late final Ticker _elasticTicker = createTicker(_tickElastic);
void _startElastic() {
if (!_elasticTicker.isActive) {
_elasticLastTick = null;
_elasticTicker.start();
}
}
// Keep one clock alive while input changes its targets. Restarting a spring
// on every pointer event repeatedly paints t=0 under continuous dragging.
void _tickElastic(Duration elapsed) {
final seconds = _elasticLastTick == null
? 1 / 60
: ((elapsed - _elasticLastTick!).inMicroseconds / 1000000).clamp(
0.0,
0.25,
);
_elasticLastTick = elapsed;
final sway = _advanceSpring(
_sway.value,
_swayVelocity,
_swayTarget,
220,
18,
seconds,
);
final drag = _advanceSpring(
_dragFollow.value,
_dragVelocity,
_dragTarget,
680,
28,
seconds,
);
_swayVelocity = sway.$2;
_dragVelocity = drag.$2;
_sway.value = sway.$1;
_dragFollow.value = drag.$1;
if ((_sway.value - _swayTarget).abs() < 0.0005 &&
_swayVelocity.abs() < 0.005 &&
(_dragFollow.value - _dragTarget).abs() < 0.0005 &&
_dragVelocity.abs() < 0.005) {
_sway.value = _swayTarget;
_dragFollow.value = _dragTarget;
_swayVelocity = _dragVelocity = 0;
_elasticTicker.stop();
_elasticLastTick = null;
}
}
(double, double) _advanceSpring(
double value,
double velocity,
double target,
double stiffness,
double damping,
double seconds,
) {
final steps = math.max(1, (seconds * 120).ceil());
final step = seconds / steps;
for (var i = 0; i < steps; i++) {
velocity += (stiffness * (target - value) - damping * velocity) * step;
value += velocity * step;
}
return (value, velocity);
}
void _swayTo(double target) {
_swayTarget = _reduceMotion ? 0 : target;
if (_reduceMotion) {
_sway.value = 0;
_swayVelocity = 0;
return;
}
_sway.animateWith(
SpringSimulation(
const SpringDescription(mass: 1, stiffness: 220, damping: 18),
_sway.value,
target,
_sway.velocity.clamp(-12, 12),
),
);
_startElastic();
}
bool _reduceMotion = false;
@@ -59,6 +132,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
void initState() {
super.initState();
_targetIndex = widget.selectedIndex;
_dragTarget = widget.selectedIndex.toDouble();
}
double get _pressure => _press.value.clamp(0.0, 1.18);
@@ -85,13 +159,34 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
_motion.value = widget.selectedIndex.toDouble();
_press.value = 0;
_tap.value = 0;
_ripple.value = 0;
_elasticTicker.stop();
_elasticLastTick = null;
_swayTarget = _swayVelocity = _dragVelocity = 0;
_dragTarget = _drag ?? widget.selectedIndex.toDouble();
_dragFollow.value = _drag ?? widget.selectedIndex.toDouble();
_sway.value = 0;
}
}
late final AnimationController _dragFollow = AnimationController.unbounded(
vsync: this,
value: widget.selectedIndex.toDouble(),
);
void _followDragTo(double target) {
_dragTarget = target;
if (_reduceMotion) {
_dragFollow.value = target;
_dragVelocity = 0;
return;
}
_startElastic();
}
double? _drag;
double _dragSpeed = 0;
double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12);
double get _position =>
(_drag != null ? _dragFollow.value : _motion.value).clamp(-0.12, 2.12);
void _moveTo(int index, {double velocity = 0, bool springyTap = false}) {
_targetIndex = index;
if (_reduceMotion) {
@@ -140,10 +235,13 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
@override
void dispose() {
_elasticTicker.dispose();
_motion.dispose();
_press.dispose();
_tap.dispose();
_sway.dispose();
_ripple.dispose();
_dragFollow.dispose();
super.dispose();
}
@@ -159,25 +257,25 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
colors: lens
? [
Color.lerp(
const Color(0xFFA1A4AA).withValues(alpha: 0.34),
const Color(0xFFA1A4AA).withValues(alpha: 0.28),
const Color(0xFFD8DBE0).withValues(alpha: 0.0252),
pressure,
)!,
Color.lerp(
const Color(0xFF8E939B).withValues(alpha: 0.26),
const Color(0xFF8E939B).withValues(alpha: 0.21),
const Color(0xFFC3C7CE).withValues(alpha: 0.0144),
pressure,
)!,
Color.lerp(
const Color(0xFFB4B7BC).withValues(alpha: 0.32),
const Color(0xFFB4B7BC).withValues(alpha: 0.26),
const Color(0xFFE5E7EB).withValues(alpha: 0.0216),
pressure,
)!,
]
: [
Colors.white.withValues(alpha: 0.38),
const Color(0xFFFDFEFF).withValues(alpha: 0.24),
Colors.white.withValues(alpha: 0.30),
Colors.white.withValues(alpha: 0.29),
const Color(0xFFFDFEFF).withValues(alpha: 0.17),
Colors.white.withValues(alpha: 0.23),
],
),
),
@@ -187,7 +285,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
return ClipRRect(
borderRadius: BorderRadius.circular(40),
child: GlassRefraction(
strength: lens ? 0.78 + pressure * 0.70 : 1.14,
strength: lens ? 0.78 + pressure * 0.70 : 0.98,
blurSigma: lens ? 1.6 - pressure * 0.6 : 1.5,
child: content,
),
@@ -202,7 +300,14 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
MediaQuery.sizeOf(context).width >= 600 ? 300.0 : 264.0,
);
return AnimatedBuilder(
animation: Listenable.merge([_motion, _press, _tap, _sway]),
animation: Listenable.merge([
_motion,
_press,
_tap,
_sway,
_ripple,
_dragFollow,
]),
builder: (context, _) {
final press = _pressure;
final pulse = _reduceMotion ? 0.0 : math.sin(math.pi * _tap.value);
@@ -213,21 +318,34 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
: math.sin(2 * math.pi * _tap.value);
final tapStretch = tapWave >= 0 ? tapWave * 0.24 : tapWave * 0.065;
final tapRise = tapWave >= 0 ? -tapWave * 0.045 : -tapWave * 0.15;
// Pointer movement wakes a finite ripple; pauses do not start idle loops.
final dragWave = _reduceMotion
? 0.0
: math.sin(2 * math.pi * _ripple.value) * press.clamp(0.0, 1.0);
final dragStretch = dragWave * (dragWave >= 0 ? 0.10 : 0.06);
final speed = _reduceMotion
? 0.0
: (_drag != null ? _dragSpeed : _motion.velocity).abs();
final stretch =
(speed / 80).clamp(0.0, 0.08) + tapStretch + press * 0.30;
(speed / 80).clamp(0.0, 0.08) +
tapStretch +
press * 0.30 +
dragStretch;
final lensWidth = _travel * (1 + stretch);
final lensHeight =
(_height - _inset * 2) *
(1 + press * 0.31 + tapRise - (speed / 180).clamp(0.0, 0.035));
(1 +
press * 0.31 +
tapRise -
dragWave * 0.08 -
(speed / 180).clamp(0.0, 0.035));
// Resize real glass bounds: sampling stays aligned without scaling text.
final sway = _reduceMotion ? 0.0 : _sway.value.clamp(-1.2, 1.2);
final bodyGrowth = press * 12 + pulse * 3 + sway.abs() * 4;
final bodyHeight =
_height + press * 5 + pulse * 1.5 - sway.abs() * 0.8;
final dockShift = sway * 8;
final leadingPull = sway * 6;
return SizedBox(
width: _width,
height: _height,
@@ -245,9 +363,9 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
boxShadow: [
BoxShadow(
color: const Color(0xFF4C6078)
.withValues(alpha: 0.075),
blurRadius: 18,
offset: const Offset(0, 6),
.withValues(alpha: 0.05),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
@@ -255,9 +373,9 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
clipBehavior: Clip.none,
children: [
Positioned(
left: -bodyGrowth / 2,
left: -bodyGrowth / 2 + math.min(leadingPull, 0),
top: (_height - bodyHeight) / 2,
width: _width + bodyGrowth,
width: _width + bodyGrowth + leadingPull.abs(),
height: bodyHeight,
child: _glass(child: const SizedBox.expand()),
),
@@ -328,7 +446,11 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
onHorizontalDragStart: index == widget.selectedIndex
? (_) {
final start = _position.clamp(0.0, 2.0);
_dragFollow.value = start;
_dragTarget = start;
_dragVelocity = _motion.velocity;
_motion.stop();
_wakeRipple();
_pressTo(_reduceMotion ? 0 : 1);
setState(() {
_drag = start;
@@ -339,18 +461,20 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
onHorizontalDragUpdate: index == widget.selectedIndex
? (details) => setState(() {
_dragSpeed = details.delta.dx / _travel * 60;
_wakeRipple();
_swayTo((_dragSpeed / 8).clamp(-1.0, 1.0));
_drag = (_position + details.delta.dx / _travel).clamp(
_drag = (_drag! + details.delta.dx / _travel).clamp(
0.0,
2.0,
);
_followDragTo(_drag!);
})
: null,
onHorizontalDragEnd: index == widget.selectedIndex
? (details) => _select(
_position.round().clamp(0, 2),
(_drag ?? _position).round().clamp(0, 2),
elastic: true,
velocity: details.velocity.pixelsPerSecond.dx / _travel,
velocity: _dragVelocity,
)
: null,
onHorizontalDragCancel: index == widget.selectedIndex
@@ -359,7 +483,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
final position = _position;
setState(() => _drag = null);
_motion.value = position;
_moveTo(widget.selectedIndex);
_moveTo(widget.selectedIndex, velocity: _dragVelocity);
_pressTo(0);
_swayTo(0);
}
@@ -515,7 +639,7 @@ class _GlassRimPainter extends CustomPainter {
outer,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = lens ? 1.8 : 2.8
..strokeWidth = lens ? 1.4 : 1.8
..maskFilter = MaskFilter.blur(BlurStyle.normal, lens ? 0.9 : 0.65)
..shader = ui.Gradient.linear(
bounds.topLeft,
@@ -532,7 +656,7 @@ class _GlassRimPainter extends CustomPainter {
outer,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = lens ? 1.25 : 1.1
..strokeWidth = lens ? 1.0 : 0.85
..shader = ui.Gradient.sweep(
bounds.center,
[
@@ -553,7 +677,7 @@ class _GlassRimPainter extends CustomPainter {
inner,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.7
..strokeWidth = 0.45
..shader = ui.Gradient.linear(
bounds.topLeft,
bounds.bottomRight,