feat: deliver RSchedule service login exams and elastic glass navigation
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
|
||||
class GlassDock extends StatefulWidget {
|
||||
const GlassDock({
|
||||
@@ -15,16 +16,59 @@ class GlassDock extends StatefulWidget {
|
||||
State<GlassDock> createState() => _GlassDockState();
|
||||
}
|
||||
|
||||
class _GlassDockState extends State<GlassDock> {
|
||||
static const _width = 192.0, _travel = 88.0;
|
||||
class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
||||
static const _width = 280.0, _travel = 88.0;
|
||||
static const _blue = Color(0xFF007AFF);
|
||||
late final AnimationController _motion = AnimationController.unbounded(
|
||||
vsync: this,
|
||||
value: widget.selectedIndex.toDouble(),
|
||||
);
|
||||
late final AnimationController _press = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 140),
|
||||
);
|
||||
double? _drag;
|
||||
double get _position => _drag ?? widget.selectedIndex.toDouble();
|
||||
void _select(int index) {
|
||||
double _dragSpeed = 0;
|
||||
double get _position => _drag ?? _motion.value.clamp(-0.12, 2.12);
|
||||
void _spring(int index, [double velocity = 0]) {
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_motion.value = index.toDouble();
|
||||
return;
|
||||
}
|
||||
_motion.animateWith(
|
||||
SpringSimulation(
|
||||
const SpringDescription(mass: 1, stiffness: 380, damping: 23),
|
||||
_position,
|
||||
index.toDouble(),
|
||||
velocity.clamp(-8, 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _select(int index, [double velocity = 0]) {
|
||||
final position = _position;
|
||||
setState(() => _drag = null);
|
||||
_motion.value = position;
|
||||
_spring(index, velocity);
|
||||
_press.reverse();
|
||||
widget.onSelected(index);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(GlassDock oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.selectedIndex != widget.selectedIndex && _drag == null) {
|
||||
_spring(widget.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motion.dispose();
|
||||
_press.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _glass({required Widget child, bool lens = false}) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(lens ? 36 : 40),
|
||||
child: BackdropFilter(
|
||||
@@ -61,50 +105,60 @@ class _GlassDockState extends State<GlassDock> {
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
width: _width,
|
||||
height: 70,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _blue.withValues(alpha: 0.035),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 5),
|
||||
Widget build(BuildContext context) => AnimatedBuilder(
|
||||
animation: Listenable.merge([_motion, _press]),
|
||||
builder: (context, _) {
|
||||
final press = Curves.easeOut.transform(_press.value);
|
||||
final speed = (_drag != null ? _dragSpeed : _motion.velocity).abs();
|
||||
final stretch = (speed / 16).clamp(0.0, 0.14) + press * 0.04;
|
||||
final lensWidth = 88 * (1 + stretch);
|
||||
final lensHeight = 56 + press * 5 - stretch * 9;
|
||||
return SizedBox(
|
||||
width: _width,
|
||||
height: 70,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _blue.withValues(alpha: 0.035),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _glass(
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: Duration(milliseconds: _drag == null ? 260 : 0),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 7 + _position * _travel,
|
||||
top: _drag == null ? 7 : 5,
|
||||
width: _drag == null ? 90 : 94,
|
||||
height: _drag == null ? 56 : 60,
|
||||
child: IgnorePointer(
|
||||
child: _glass(lens: true, child: const SizedBox.expand()),
|
||||
),
|
||||
child: _glass(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 8 + _position * _travel - (lensWidth - 88) / 2,
|
||||
top: (70 - lensHeight) / 2,
|
||||
width: lensWidth,
|
||||
height: lensHeight,
|
||||
child: IgnorePointer(
|
||||
child: _glass(lens: true, child: const SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 7,
|
||||
bottom: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
_item(0, Icons.calendar_month_rounded, '课表'),
|
||||
_item(1, Icons.assignment_outlined, '考试'),
|
||||
_item(2, Icons.person_rounded, '个人设置'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 7,
|
||||
bottom: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
_item(0, Icons.calendar_month_rounded, '课表'),
|
||||
_item(1, Icons.person_rounded, '我的'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
Widget _item(int index, IconData icon, String label) => Expanded(
|
||||
child: Semantics(
|
||||
@@ -116,31 +170,59 @@ class _GlassDockState extends State<GlassDock> {
|
||||
child: GestureDetector(
|
||||
key: index == widget.selectedIndex ? const Key('dock-cursor') : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => _press.forward(),
|
||||
onTapCancel: () {
|
||||
if (_drag == null) _press.reverse();
|
||||
},
|
||||
onTap: () => _select(index),
|
||||
onHorizontalDragStart: index == widget.selectedIndex
|
||||
? (_) => setState(() => _drag = widget.selectedIndex.toDouble())
|
||||
? (_) {
|
||||
final start = _position.clamp(0.0, 2.0);
|
||||
_motion.stop();
|
||||
_press.forward();
|
||||
setState(() {
|
||||
_drag = start;
|
||||
_dragSpeed = 0;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onHorizontalDragUpdate: index == widget.selectedIndex
|
||||
? (details) => setState(
|
||||
() => _drag = (_position + details.delta.dx / _travel).clamp(
|
||||
0,
|
||||
1,
|
||||
),
|
||||
)
|
||||
? (details) => setState(() {
|
||||
_dragSpeed = details.delta.dx / _travel * 60;
|
||||
_drag = (_position + details.delta.dx / _travel).clamp(
|
||||
0.0,
|
||||
2.0,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
onHorizontalDragEnd: index == widget.selectedIndex
|
||||
? (_) => _select(_position >= 0.5 ? 1 : 0)
|
||||
? (details) => _select(
|
||||
_position.round().clamp(0, 2),
|
||||
details.velocity.pixelsPerSecond.dx / _travel,
|
||||
)
|
||||
: null,
|
||||
onHorizontalDragCancel: index == widget.selectedIndex
|
||||
? () => setState(() => _drag = null)
|
||||
? () {
|
||||
final position = _position;
|
||||
setState(() => _drag = null);
|
||||
_motion.value = position;
|
||||
_spring(widget.selectedIndex);
|
||||
_press.reverse();
|
||||
}
|
||||
: null,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 27,
|
||||
color: index == (_position >= 0.5 ? 1 : 0)
|
||||
? _blue
|
||||
: _blue.withValues(alpha: 0.48),
|
||||
child: AnimatedScale(
|
||||
scale: index == _position.round().clamp(0, 2)
|
||||
? 1 + _press.value * 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Retains all tab states and scroll positions, with no page-swipe recognizer.
|
||||
class TabDeck extends StatefulWidget {
|
||||
const TabDeck({super.key, required this.index, required this.children});
|
||||
final int index;
|
||||
final List<Widget> children;
|
||||
@override
|
||||
State<TabDeck> createState() => _TabDeckState();
|
||||
}
|
||||
|
||||
class _TabDeckState extends State<TabDeck> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _motion = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 330),
|
||||
value: 1,
|
||||
);
|
||||
int? _outgoing;
|
||||
int _direction = 1;
|
||||
@override
|
||||
void didUpdateWidget(TabDeck oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.index != widget.index) {
|
||||
_outgoing = oldWidget.index;
|
||||
_direction = widget.index > oldWidget.index ? 1 : -1;
|
||||
if (MediaQuery.disableAnimationsOf(context)) {
|
||||
_motion.value = 1;
|
||||
} else {
|
||||
_motion.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnimatedBuilder(
|
||||
animation: _motion,
|
||||
builder: (context, _) {
|
||||
final t = Curves.easeOutCubic.transform(_motion.value);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
for (var i = 0; i < widget.children.length; i++)
|
||||
Offstage(
|
||||
offstage:
|
||||
i != widget.index && (i != _outgoing || _motion.isCompleted),
|
||||
child: TickerMode(
|
||||
enabled: i == widget.index,
|
||||
child: IgnorePointer(
|
||||
ignoring: i != widget.index,
|
||||
child: ExcludeSemantics(
|
||||
excluding: i != widget.index,
|
||||
child: Opacity(
|
||||
opacity: i == widget.index
|
||||
? t
|
||||
: (i == _outgoing ? 1 - t : 0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(
|
||||
(i == widget.index ? 1 - t : -t) * 22 * _direction,
|
||||
0,
|
||||
),
|
||||
child: widget.children[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user