Files
TPSchedule/mobile/lib/widgets/glass_dock.dart
T

406 lines
12 KiB
Dart

import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'glass_refraction.dart';
class GlassDock extends StatefulWidget {
const GlassDock({
super.key,
required this.selectedIndex,
required this.onSelected,
});
final int selectedIndex;
final ValueChanged<int> onSelected;
@override
State<GlassDock> createState() => _GlassDockState();
}
class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
static const _width = 264.0, _height = 68.0, _inset = 3.0, _travel = 86.0;
static const _blue = Color(0xFF007AFF);
late final AnimationController _motion = AnimationController.unbounded(
vsync: this,
value: widget.selectedIndex.toDouble(),
);
late final AnimationController _press = AnimationController.unbounded(
vsync: this,
value: 0,
);
bool _reduceMotion = false;
late int _targetIndex;
bool _elasticMotion = false;
@override
void initState() {
super.initState();
_targetIndex = widget.selectedIndex;
}
double get _pressure => _press.value.clamp(0.0, 1.18);
void _pressTo(double target) {
if (_reduceMotion) {
_press.value = target;
return;
}
_press.animateWith(
SpringSimulation(
const SpringDescription(mass: 1, stiffness: 440, damping: 17),
_press.value,
target,
_press.velocity,
),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_reduceMotion = MediaQuery.disableAnimationsOf(context);
}
double? _drag;
double _dragSpeed = 0;
double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12);
void _moveTo(int index, {bool elastic = false, double velocity = 0}) {
_targetIndex = index;
_elasticMotion = elastic && !_reduceMotion;
if (_reduceMotion) {
_motion.value = index.toDouble();
return;
}
if (elastic) {
_motion.animateWith(
SpringSimulation(
const SpringDescription(mass: 1, stiffness: 380, damping: 19),
_position,
index.toDouble(),
velocity.clamp(-8, 8),
),
);
} else {
_motion.animateTo(
index.toDouble(),
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
}
}
void _select(int index, {bool elastic = false, double velocity = 0}) {
final position = _position;
setState(() => _drag = null);
_motion.value = position;
_moveTo(index, elastic: elastic, velocity: velocity);
_pressTo(0);
widget.onSelected(index);
}
@override
void didUpdateWidget(GlassDock oldWidget) {
super.didUpdateWidget(oldWidget);
// The parent acknowledges onSelected with a rebuild. Restarting here used
// to erase the release velocity and start a second spring for the same tab.
if (widget.selectedIndex != _targetIndex && _drag == null) {
_moveTo(widget.selectedIndex);
}
}
@override
void dispose() {
_motion.dispose();
_press.dispose();
super.dispose();
}
Widget _glass({required Widget child, bool lens = false}) {
final pressure = _pressure.clamp(0.0, 1.0);
final content = CustomPaint(
foregroundPainter: _GlassRimPainter(lens: lens, pressure: pressure),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: lens
? [
Color.lerp(
const Color(0xFFA1A4AA).withValues(alpha: 0.34),
const Color(0xFFD8DBE0).withValues(alpha: 0.0252),
pressure,
)!,
Color.lerp(
const Color(0xFF8E939B).withValues(alpha: 0.26),
const Color(0xFFC3C7CE).withValues(alpha: 0.0144),
pressure,
)!,
Color.lerp(
const Color(0xFFB4B7BC).withValues(alpha: 0.32),
const Color(0xFFE5E7EB).withValues(alpha: 0.0216),
pressure,
)!,
]
: [
Colors.white.withValues(alpha: 0.05),
_blue.withValues(alpha: 0.008),
Colors.white.withValues(alpha: 0.018),
],
),
),
child: child,
),
);
return ClipRRect(
borderRadius: BorderRadius.circular(40),
child: GlassRefraction(
strength: lens ? 0.70 + pressure * 0.70 : 0.95,
blurSigma: lens ? 1.6 - pressure * 0.6 : 1.4,
child: content,
),
);
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: Listenable.merge([_motion, _press]),
builder: (context, _) {
final press = _pressure;
final speed = _reduceMotion
? 0.0
: (_drag != null
? _dragSpeed
: _elasticMotion
? _motion.velocity
: 0.0)
.abs();
final stretch = (speed / 16).clamp(0.0, 0.14) + press * 0.30;
final lensWidth = _travel * (1 + stretch);
final lensHeight =
(_height - _inset * 2) *
(1 + press * 0.31 - (speed / 16).clamp(0.0, 0.07));
return SizedBox(
width: _width,
height: _height,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
color: const Color(0xFF4C6078).withValues(alpha: 0.075),
blurRadius: 16,
offset: const Offset(0, 5),
),
],
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(child: _glass(child: const SizedBox.expand())),
Positioned(
left: (_inset + _position * _travel - (lensWidth - _travel) / 2)
.clamp(1.0, _width - lensWidth - 1),
top: (_height - lensHeight) / 2,
width: lensWidth,
height: lensHeight,
child: IgnorePointer(
child: _glass(lens: true, child: const SizedBox.expand()),
),
),
Positioned(
left: _inset,
right: _inset,
top: _inset,
bottom: _inset,
child: Row(
children: [
_item(0, Icons.calendar_month_rounded, '课表'),
_item(1, Icons.assignment_rounded, '考试'),
_item(2, Icons.person_rounded, '个人设置'),
],
),
),
],
),
),
);
},
);
Widget _item(int index, IconData icon, String label) => Expanded(
child: Semantics(
label: label,
button: true,
selected: index == widget.selectedIndex,
excludeSemantics: true,
onTap: () => _select(index),
child: GestureDetector(
key: index == widget.selectedIndex ? const Key('dock-cursor') : null,
behavior: HitTestBehavior.opaque,
onTapDown: index == widget.selectedIndex ? (_) => _pressTo(1) : null,
onTapCancel: () {
if (_drag == null) _pressTo(0);
},
onTap: () => _select(index),
onHorizontalDragStart: index == widget.selectedIndex
? (_) {
final start = _position.clamp(0.0, 2.0);
_motion.stop();
_pressTo(1);
setState(() {
_drag = start;
_dragSpeed = 0;
});
}
: null,
onHorizontalDragUpdate: index == widget.selectedIndex
? (details) => setState(() {
_dragSpeed = details.delta.dx / _travel * 60;
_drag = (_position + details.delta.dx / _travel).clamp(
0.0,
2.0,
);
})
: null,
onHorizontalDragEnd: index == widget.selectedIndex
? (details) => _select(
_position.round().clamp(0, 2),
elastic: true,
velocity: details.velocity.pixelsPerSecond.dx / _travel,
)
: null,
onHorizontalDragCancel: index == widget.selectedIndex
? () {
if (!mounted) return;
final position = _position;
setState(() => _drag = null);
_motion.value = position;
_moveTo(widget.selectedIndex, elastic: true);
_pressTo(0);
}
: null,
child: Center(
child: AnimatedScale(
scale: index == _position.round().clamp(0, 2)
? 1 + _pressure * 0.08
: 1,
duration: const Duration(milliseconds: 130),
child: Icon(
icon,
size: 27,
color: index == _position.round().clamp(0, 2)
? _blue
: _blue.withValues(alpha: 0.48),
),
),
),
),
),
);
}
/// A diffuse silver bevel and thin directional glints show optical thickness.
/// The painted highlights stay on the edge so background colour passes through.
class _GlassRimPainter extends CustomPainter {
const _GlassRimPainter({required this.lens, required this.pressure});
final bool lens;
final double pressure;
@override
void paint(Canvas canvas, Size size) {
final edge = lens ? const Color(0xFF7B838F) : const Color(0xFFAFBECF);
// A quiet grey selection at rest; the glossy lens rim returns on touch.
final opacity = lens ? 0.25 + pressure * 0.75 : 1.0;
final bounds = Offset.zero & size;
final outer = RRect.fromRectAndRadius(
bounds.deflate(0.7),
Radius.circular(size.height / 2),
);
final inner = RRect.fromRectAndRadius(
bounds.deflate(2.2),
Radius.circular(size.height / 2 - 1.5),
);
canvas.drawRRect(
outer,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = lens ? 1.8 : 2.2
..maskFilter = MaskFilter.blur(BlurStyle.normal, lens ? 0.9 : 0.45)
..shader = ui.Gradient.linear(
bounds.topLeft,
bounds.bottomRight,
[
Colors.white.withValues(alpha: 0.24 * opacity),
edge.withValues(alpha: 0.04 * opacity),
Colors.white.withValues(alpha: 0.14 * opacity),
],
[0.0, 0.55, 1.0],
),
);
canvas.drawRRect(
outer,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = lens ? 1.25 : 1.1
..shader = ui.Gradient.sweep(
bounds.center,
[
Colors.white.withValues(alpha: 0.96 * opacity),
edge.withValues(alpha: (lens ? 0.30 : 0.15) * opacity),
Colors.white.withValues(alpha: 0.10 * opacity),
Colors.white.withValues(alpha: 0.94 * opacity),
edge.withValues(alpha: 0.12 * opacity),
Colors.white.withValues(alpha: 0.96 * opacity),
],
[0.0, 0.18, 0.38, 0.60, 0.80, 1.0],
TileMode.clamp,
-math.pi / 2,
math.pi * 1.5,
),
);
canvas.drawRRect(
inner,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.7
..shader = ui.Gradient.linear(
bounds.topLeft,
bounds.bottomRight,
[
Colors.white.withValues(alpha: 0.20 * opacity),
edge.withValues(alpha: (lens ? 0.13 : 0.07) * opacity),
Colors.white.withValues(alpha: 0.28 * opacity),
],
[0.0, 0.5, 1.0],
),
);
final shine = Path()
..moveTo(size.height * 0.32, 4)
..quadraticBezierTo(
size.width * 0.5,
1.8,
size.width - size.height * 0.35,
4,
);
canvas.drawPath(
shine,
Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 0.8
..shader = ui.Gradient.linear(
Offset.zero,
Offset(size.width, 0),
[
Colors.white.withValues(alpha: 0),
Colors.white.withValues(alpha: 0.50 * opacity),
Colors.white.withValues(alpha: 0),
],
[0.0, 0.5, 1.0],
),
);
}
@override
bool shouldRepaint(_GlassRimPainter oldDelegate) =>
oldDelegate.lens != lens || oldDelegate.pressure != pressure;
}