fix: keep dock drag springs live and add elastic leading-edge pull
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
const appVersion = '0.7.8';
|
||||
const appBuildNumber = 30;
|
||||
const appBuildNumber = 31;
|
||||
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hepan_everyday
|
||||
description: TPSchedule 学生课表
|
||||
publish_to: none
|
||||
version: 0.7.8+30
|
||||
version: 0.7.8+31
|
||||
environment:
|
||||
sdk: ^3.13.5
|
||||
dependencies:
|
||||
|
||||
@@ -43,8 +43,8 @@ void main() {
|
||||
colour = mix(colour, reflected, min(edge_light * 0.18, 0.22));
|
||||
// A grazing-angle white reflection gives the curved rim visible thickness;
|
||||
// the broad centre continues to transmit the live, lightly softened scene.
|
||||
colour.rgb = mix(colour.rgb, vec3(colour.a), min(edge_light * 0.20, 0.22));
|
||||
float reflection = edge_light * 0.10;
|
||||
colour.rgb = mix(colour.rgb, vec3(colour.a), min(edge_light * 0.15, 0.18));
|
||||
float reflection = edge_light * 0.075;
|
||||
colour.rgb = min(colour.rgb + reflection * colour.a, vec3(colour.a));
|
||||
frag_color = colour;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,37 @@ import 'navigation_test.dart' show uiController;
|
||||
|
||||
void main() {
|
||||
setUpAll(tzdata.initializeTimeZones);
|
||||
testWidgets('drag tugs the leading edge and lens follows with elastic lag', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_dock());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(Icons.assignment_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
final lens = find.byType(GlassRefraction).last;
|
||||
final body = find.byType(GlassRefraction).first;
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 110));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
await gesture.moveBy(const Offset(24, 0));
|
||||
await tester.pump();
|
||||
final initialX = tester.getCenter(lens).dx;
|
||||
final initialBody = tester.getRect(body);
|
||||
await gesture.moveBy(const Offset(60, 0));
|
||||
await tester.pump();
|
||||
expect(tester.getCenter(lens).dx - initialX, lessThan(45));
|
||||
await tester.pump(const Duration(milliseconds: 180));
|
||||
expect(tester.getCenter(lens).dx, greaterThan(initialX + 35));
|
||||
final draggedBody = tester.getRect(body);
|
||||
final leading = draggedBody.right - initialBody.right;
|
||||
final trailing = draggedBody.left - initialBody.left;
|
||||
expect(leading - trailing, greaterThan(7));
|
||||
await gesture.cancel();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
testWidgets('inactive icon press leaves the cursor at its resting size', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -80,6 +111,42 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(body), resting.size);
|
||||
});
|
||||
testWidgets(
|
||||
'lens keeps softly stretching and squeezing during an even drag',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_dock());
|
||||
await tester.pumpAndSettle();
|
||||
final lens = find.byType(GlassRefraction).last;
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('dock-cursor'))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 110));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
await gesture.moveBy(const Offset(24, 0));
|
||||
await tester.pump();
|
||||
final widths = <double>[], heights = <double>[], centres = <double>[];
|
||||
for (var i = 0; i < 22; i++) {
|
||||
await gesture.moveBy(const Offset(5, 0));
|
||||
await tester.pump(const Duration(milliseconds: 20));
|
||||
if (i > 3) {
|
||||
final size = tester.getSize(lens);
|
||||
widths.add(size.width);
|
||||
heights.add(size.height);
|
||||
centres.add(tester.getCenter(lens).dx);
|
||||
}
|
||||
}
|
||||
expect(widths.reduce(math.max) - widths.reduce(math.min), greaterThan(6));
|
||||
expect(
|
||||
heights.reduce(math.max) - heights.reduce(math.min),
|
||||
greaterThan(2),
|
||||
);
|
||||
expect(centres.last - centres.first, greaterThan(50));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getSize(lens).width, closeTo(86, 0.1));
|
||||
expect(tester.getSize(lens).height, closeTo(52, 0.1));
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'rapid reverse taps keep position continuous and finish on last tab',
|
||||
(tester) async {
|
||||
@@ -258,7 +325,9 @@ void main() {
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(86, 0));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 180));
|
||||
for (var frame = 0; frame < 12; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
expect(tester.getCenter(icon).dy, lessThan(y - 1));
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
expect(tester.getCenter(icon).dy, closeTo(y, 0.1));
|
||||
@@ -278,7 +347,9 @@ void main() {
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(86, 0));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 180));
|
||||
for (var frame = 0; frame < 12; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
}
|
||||
final before = tester.getRect(icon);
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
|
||||
@@ -23,8 +23,8 @@ void main() {
|
||||
// while its original cool colour remains visible through the material.
|
||||
final red = _channel(pixels, 160, 43, 0);
|
||||
final green = _channel(pixels, 160, 43, 1);
|
||||
expect(red, greaterThan(75));
|
||||
expect(red, lessThan(115));
|
||||
expect(red, greaterThan(50));
|
||||
expect(red, lessThan(80));
|
||||
expect(green - red, closeTo(13, 6));
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user