7c9627fffb
Changelog: Optional two-tier Liquid Glass and slide-to-pick menus
1993 lines
57 KiB
Dart
1993 lines
57 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:fl_clash/ui/context.dart';
|
|
import 'package:fl_clash/ui/icons/icons.dart';
|
|
import 'package:fl_clash/ui/shape.dart';
|
|
import 'package:fl_clash/ui/surface.dart';
|
|
import 'package:fl_clash/ui/typography.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/physics.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_color_utilities/material_color_utilities.dart'
|
|
show Hct;
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
import 'liquid_glass.dart';
|
|
|
|
const double _barHeight = 62;
|
|
const double _barPadding = 4;
|
|
const double _edgeMargin = 21;
|
|
const double _shadowRoom = 8;
|
|
const double _hitSlop = 8;
|
|
const double _maxItemExtent = 86;
|
|
const double _minLensWidth = 84;
|
|
const _fullWidthCount = 5;
|
|
const double _iconSize = 28;
|
|
const double _labelGap = 2;
|
|
const double _labelInset = 2;
|
|
const double _labelSize = 10;
|
|
const double _labelLeading = 14.5;
|
|
const double _minLabelSize = 9;
|
|
const double _pressGrowth = 1 / 8;
|
|
const double _maxPressGrowth = 16;
|
|
const double _lensGrowth = 8;
|
|
const double _lensMagnify = 0.16;
|
|
const double _liftSnap = 0.975;
|
|
const double _liftRelease = 0.04;
|
|
const double _hoverMagnet = 0.2;
|
|
const double _hoverParallax = 0.5;
|
|
const double _hoverAlpha = 0.08;
|
|
const double _hoverSwell = 0.4;
|
|
const double _jellySpeed = 3;
|
|
const double _jellyStretch = 0.2;
|
|
const double _jellyBrake = 25;
|
|
const double _jellySquash = 0.15;
|
|
const double _jellyAspect = 1.4;
|
|
const double _glowReach = 28;
|
|
const double _glowGrowth = 3;
|
|
const double _glowPeak = 0.06;
|
|
const double _glowWash = 0.7;
|
|
const double _overdrag = 0.35;
|
|
const double _pullLimit = 7 / 32;
|
|
const double _pullStretch = 0.5;
|
|
const _flingProjection = Duration(milliseconds: 100);
|
|
final _trackSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 120),
|
|
);
|
|
final _pressSpring = SpringDescription.withDampingRatio(
|
|
mass: 1,
|
|
stiffness: 311,
|
|
ratio: 0.593,
|
|
);
|
|
final _followSpring = SpringDescription.withDampingRatio(
|
|
mass: 1,
|
|
stiffness: 1024,
|
|
ratio: 0.9,
|
|
);
|
|
final _liftSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 250),
|
|
);
|
|
final _travelSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 400),
|
|
bounce: 0.15,
|
|
);
|
|
final _jellySpring = SpringDescription.withDampingRatio(
|
|
mass: 1,
|
|
stiffness: 100,
|
|
ratio: 0.4,
|
|
);
|
|
final _settleSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 500),
|
|
bounce: 0.32,
|
|
);
|
|
final _hoverSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 260),
|
|
bounce: 0.18,
|
|
);
|
|
final _fadeSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 200),
|
|
);
|
|
final _glowInSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 100),
|
|
);
|
|
final _glowOutSpring = SpringDescription.withDurationAndBounce(
|
|
duration: const Duration(milliseconds: 500),
|
|
);
|
|
|
|
double _rubberBand(double overshoot, double limit) {
|
|
final pull = 1 - 1 / (overshoot.abs() * 0.55 / limit + 1);
|
|
return limit * pull * overshoot.sign;
|
|
}
|
|
|
|
double _saturate(double value) => 1 - 2 / (math.exp(2 * value) + 1);
|
|
|
|
/// The lens's box, measured from the start edge of the destination at 0.
|
|
Rect _lensRect({
|
|
required double position,
|
|
required double stretch,
|
|
required double pitch,
|
|
required double width,
|
|
required double height,
|
|
required double lift,
|
|
}) {
|
|
final growth = _lensGrowth * 2 * lift;
|
|
final lensWidth = (width + growth) * (1 + stretch);
|
|
final lensHeight = (height + growth) * (1 - stretch * _jellyAspect);
|
|
return Rect.fromLTWH(
|
|
(position + 0.5) * pitch - lensWidth / 2,
|
|
(height - lensHeight) / 2,
|
|
lensWidth,
|
|
lensHeight,
|
|
);
|
|
}
|
|
|
|
/// Spaces destinations as iOS's tab bar does: the resting lens overhangs its
|
|
/// destination by [_barPadding] and never narrows below [_minLensWidth].
|
|
({double pitch, double lens, double inset}) _dockLayout(
|
|
double span,
|
|
int count,
|
|
) {
|
|
if (count <= 1) {
|
|
return (pitch: span - _barPadding * 2, lens: span, inset: _barPadding);
|
|
}
|
|
final pitch = (span - _barPadding * 2) / count;
|
|
if (pitch + _barPadding * 2 >= _minLensWidth) {
|
|
return (pitch: pitch, lens: pitch + _barPadding * 2, inset: _barPadding);
|
|
}
|
|
final lens = math.min(_minLensWidth, span);
|
|
final closed = (span - lens) / (count - 1);
|
|
return (pitch: closed, lens: lens, inset: (lens - closed) / 2);
|
|
}
|
|
|
|
/// A value that springs toward a target the finger may move every frame.
|
|
///
|
|
/// [AnimationController.animateWith] restarts its ticker, whose first frame
|
|
/// reads no elapsed time, so retargeting it on every pointer move holds the
|
|
/// value still for as long as the finger keeps moving.
|
|
class _Spring extends ChangeNotifier implements ValueListenable<double> {
|
|
_Spring(TickerProvider vsync, this._value) {
|
|
_ticker = vsync.createTicker(_tick);
|
|
}
|
|
|
|
late final Ticker _ticker;
|
|
double _value;
|
|
double _target = 0;
|
|
SpringSimulation? _simulation;
|
|
SpringDescription? _spring;
|
|
double _now = 0;
|
|
double _start = 0;
|
|
|
|
@override
|
|
double get value => _value;
|
|
|
|
double get target => _simulation == null ? _value : _target;
|
|
|
|
double get velocity => _simulation?.dx(_now - _start) ?? 0;
|
|
|
|
double get acceleration {
|
|
final spring = _spring;
|
|
if (_simulation == null || spring == null) {
|
|
return 0;
|
|
}
|
|
return (spring.stiffness * (_target - _value) - spring.damping * velocity) /
|
|
spring.mass;
|
|
}
|
|
|
|
void springTo(double target, SpringDescription spring) {
|
|
_simulation = SpringSimulation(spring, _value, target, velocity);
|
|
_spring = spring;
|
|
_target = target;
|
|
if (_ticker.isActive) {
|
|
_start = _now;
|
|
return;
|
|
}
|
|
_now = _start = 0;
|
|
_ticker.start();
|
|
}
|
|
|
|
void jumpTo(double value) {
|
|
_simulation = null;
|
|
_ticker.stop();
|
|
_value = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _tick(Duration elapsed) {
|
|
_now = elapsed.inMicroseconds / Duration.microsecondsPerSecond;
|
|
final simulation = _simulation!;
|
|
final time = _now - _start;
|
|
if (simulation.isDone(time)) {
|
|
_value = _target;
|
|
_simulation = null;
|
|
_ticker.stop();
|
|
} else {
|
|
_value = simulation.x(time);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ticker.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
// A soft, wide drop in the manner of iOS rather than a Material elevation.
|
|
List<BoxShadow> dockShadows(ColorScheme colorScheme) {
|
|
final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0;
|
|
return [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.08 * strength),
|
|
blurRadius: 24,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.04 * strength),
|
|
blurRadius: 3,
|
|
offset: const Offset(0, 1),
|
|
),
|
|
];
|
|
}
|
|
|
|
// iOS 27 shades its glass bars only below, faintly, and no darker in dark mode.
|
|
List<BoxShadow> dockGlassShadows(ColorScheme colorScheme) => [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.013),
|
|
blurRadius: 3.8,
|
|
offset: const Offset(0, 6.5),
|
|
),
|
|
];
|
|
|
|
/// What the dock's parts stand on: a floating surface, or glass.
|
|
class _DockSurface extends StatelessWidget {
|
|
const _DockSurface({
|
|
required this.glass,
|
|
required this.style,
|
|
required this.shadows,
|
|
required this.glow,
|
|
required this.child,
|
|
});
|
|
|
|
final bool glass;
|
|
final LiquidGlassStyle style;
|
|
final List<BoxShadow> shadows;
|
|
final ValueListenable<LiquidGlassGlow> glow;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = context.colorScheme;
|
|
if (glass) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: glow,
|
|
builder: (_, glow, child) => LiquidGlass(
|
|
style: style,
|
|
shadows: shadows,
|
|
glow: glow,
|
|
child: child,
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
return DecoratedBox(
|
|
decoration: ShapeDecoration(
|
|
shape: AppShape.full,
|
|
shadows: dockShadows(colorScheme),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
({double width, double height}) _measureLabel(
|
|
BuildContext context,
|
|
String label,
|
|
TextStyle? style,
|
|
) {
|
|
final painter = TextPainter(
|
|
text: TextSpan(text: label, style: style),
|
|
textScaler: MediaQuery.textScalerOf(context),
|
|
textDirection: Directionality.of(context),
|
|
maxLines: 1,
|
|
)..layout();
|
|
final size = (width: painter.width, height: painter.height);
|
|
painter.dispose();
|
|
return size;
|
|
}
|
|
|
|
class NavigationDockDestination {
|
|
const NavigationDockDestination({required this.glyph, required this.label});
|
|
|
|
final Glyph glyph;
|
|
final String label;
|
|
}
|
|
|
|
class NavigationDock extends StatelessWidget {
|
|
const NavigationDock({
|
|
super.key,
|
|
required this.destinations,
|
|
required this.selectedIndex,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final List<NavigationDockDestination> destinations;
|
|
final int selectedIndex;
|
|
final ValueChanged<int> onSelected;
|
|
|
|
static double heightOf(BuildContext context) {
|
|
return _barHeight +
|
|
MediaQuery.textScalerOf(context).scale(_labelSize) -
|
|
_labelSize;
|
|
}
|
|
|
|
static double _bottomMarginOf(BuildContext context) =>
|
|
math.max(_edgeMargin, MediaQuery.paddingOf(context).bottom);
|
|
|
|
/// The room content scrolling under the dock leaves at its foot.
|
|
static double insetOf(BuildContext context) =>
|
|
heightOf(context) + _bottomMarginOf(context);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final height = heightOf(context);
|
|
final dock = Padding(
|
|
padding: EdgeInsets.fromLTRB(
|
|
_edgeMargin,
|
|
_shadowRoom,
|
|
_edgeMargin,
|
|
_bottomMarginOf(context),
|
|
),
|
|
child: _HitSlop(
|
|
child: SizedBox(
|
|
height: height,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Flexible(
|
|
child: _HitSlop(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: destinations.length >= _fullWidthCount
|
|
? double.infinity
|
|
: destinations.length * _maxItemExtent +
|
|
_barPadding * 4,
|
|
),
|
|
child: FloatingNavigationBar(
|
|
destinations: destinations,
|
|
selectedIndex: selectedIndex,
|
|
onSelected: onSelected,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
// The bar rebuilds every frame its lens or a press moves; a scope of its
|
|
// own keeps that to the dock (.agents/architecture.md, Rebuild Scopes).
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
height: _shadowRoom + height + _bottomMarginOf(context),
|
|
child: RepaintBoundary(child: LayoutBuilder(builder: (_, _) => dock)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Lets a touch that misses the dock by at most [_hitSlop] still reach it.
|
|
class _HitSlop extends SingleChildRenderObjectWidget {
|
|
const _HitSlop({super.child});
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) => _RenderHitSlop();
|
|
}
|
|
|
|
class _RenderHitSlop extends RenderProxyBox {
|
|
@override
|
|
bool hitTest(BoxHitTestResult result, {required Offset position}) {
|
|
if (size.contains(position)) {
|
|
return super.hitTest(result, position: position);
|
|
}
|
|
if (size.isEmpty ||
|
|
!(Offset.zero & size).inflate(_hitSlop).contains(position)) {
|
|
return false;
|
|
}
|
|
if (!hitTestChildren(result, position: _onSpine(position))) {
|
|
return false;
|
|
}
|
|
result.add(BoxHitTestEntry(this, position));
|
|
return true;
|
|
}
|
|
|
|
// A pill's rounded ends clip hit tests; its straight core never does.
|
|
Offset _onSpine(Offset position) {
|
|
final radius = size.shortestSide / 2;
|
|
return Offset(
|
|
position.dx.clamp(radius, size.width - radius),
|
|
size.height / 2,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Grows under a press and stretches after a dragging finger, springing back
|
|
/// on release, as the bar's lens does.
|
|
///
|
|
/// The press only paints: layout, hit testing, and anything anchored to the
|
|
/// child, such as a popup menu or a tooltip, see it at rest.
|
|
class ElasticPress extends StatefulWidget {
|
|
const ElasticPress({
|
|
super.key,
|
|
this.enabled = true,
|
|
this.pullPastEdges = false,
|
|
this.maxGrowth = _maxPressGrowth,
|
|
this.maxPull,
|
|
this.panelOf,
|
|
required this.child,
|
|
});
|
|
|
|
/// Leaves a press on the button inside to the swell alone.
|
|
static const buttonStyle = ButtonStyle(
|
|
splashFactory: NoSplash.splashFactory,
|
|
overlayColor: WidgetStateMapper<Color?>({
|
|
WidgetState.pressed: Colors.transparent,
|
|
}),
|
|
);
|
|
|
|
final bool enabled;
|
|
final bool pullPastEdges;
|
|
final double maxGrowth;
|
|
final double? maxPull;
|
|
|
|
/// Where the pressed panel lies in this box, when the box is larger.
|
|
final Rect Function(RenderBox box)? panelOf;
|
|
|
|
final Widget child;
|
|
|
|
static ElasticPressHandle? maybeOf(BuildContext context) =>
|
|
context.findAncestorStateOfType<_ElasticPressState>();
|
|
|
|
/// Where [box] paints under the presses around it, in its own coordinates.
|
|
static Rect paintBoundsOf(RenderBox box) {
|
|
final bounds = Offset.zero & box.size;
|
|
var painted = Matrix4.identity();
|
|
RenderObject from = box;
|
|
for (var node = box.parent; node != null; node = node.parent) {
|
|
if (node is _RenderPressTransform && node.hasSize) {
|
|
painted = node._transform.multiplied(
|
|
from.getTransformTo(node).multiplied(painted),
|
|
);
|
|
from = node;
|
|
}
|
|
}
|
|
final rest = Matrix4.tryInvert(box.getTransformTo(from));
|
|
if (identical(from, box) || rest == null) {
|
|
return bounds;
|
|
}
|
|
return MatrixUtils.transformRect(rest.multiplied(painted), bounds);
|
|
}
|
|
|
|
@override
|
|
State<ElasticPress> createState() => _ElasticPressState();
|
|
}
|
|
|
|
/// Drives an [ElasticPress] with a press it does not hear, at global positions.
|
|
abstract interface class ElasticPressHandle {
|
|
void press(Offset position);
|
|
|
|
void drag(Offset position);
|
|
|
|
void release();
|
|
}
|
|
|
|
class _ElasticPressState extends State<ElasticPress>
|
|
with TickerProviderStateMixin
|
|
implements ElasticPressHandle {
|
|
late final _Spring _lift = _Spring(this, 0);
|
|
late final _Spring _pullX = _Spring(this, 0);
|
|
late final _Spring _pullY = _Spring(this, 0);
|
|
late final Listenable _motion = Listenable.merge([_lift, _pullX, _pullY]);
|
|
int? _pointer;
|
|
Offset _origin = Offset.zero;
|
|
bool _releasing = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_lift.addListener(_settle);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_lift.dispose();
|
|
_pullX.dispose();
|
|
_pullY.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _swell(Offset origin) {
|
|
_origin = origin;
|
|
_releasing = false;
|
|
_lift.springTo(1, _pressSpring);
|
|
}
|
|
|
|
void _handlePointerDown(PointerDownEvent event) {
|
|
if (!widget.enabled ||
|
|
_pointer != null ||
|
|
event.buttons & kPrimaryButton == 0) {
|
|
return;
|
|
}
|
|
_pointer = event.pointer;
|
|
_swell(event.localPosition);
|
|
}
|
|
|
|
// A pointer's events keep reaching the hit-test path recorded at its down,
|
|
// so a press removed mid-gesture still hears its moves and release.
|
|
void _handlePointerMove(PointerMoveEvent event) {
|
|
if (!mounted || event.pointer != _pointer) {
|
|
return;
|
|
}
|
|
_pullToward(event.localPosition);
|
|
}
|
|
|
|
void _pullToward(Offset position) {
|
|
final box = context.findRenderObject()! as RenderBox;
|
|
final panel = widget.panelOf?.call(box) ?? Offset.zero & box.size;
|
|
final limit = widget.maxPull ?? panel.shortestSide * _pullLimit;
|
|
final pull = widget.pullPastEdges
|
|
? position -
|
|
Offset(
|
|
position.dx.clamp(panel.left, panel.right),
|
|
position.dy.clamp(panel.top, panel.bottom),
|
|
)
|
|
: position - _origin;
|
|
_pullX.springTo(_rubberBand(pull.dx, limit), _trackSpring);
|
|
_pullY.springTo(_rubberBand(pull.dy, limit), _trackSpring);
|
|
}
|
|
|
|
void _handlePointerEnd(PointerEvent event) {
|
|
if (!mounted || event.pointer != _pointer) {
|
|
return;
|
|
}
|
|
_release();
|
|
}
|
|
|
|
void _release() {
|
|
_pointer = null;
|
|
_pullX.springTo(0, _pressSpring);
|
|
_pullY.springTo(0, _pressSpring);
|
|
_releasing = true;
|
|
_settle();
|
|
}
|
|
|
|
// A tap lets go before the swell has risen; it rises all the way first, as
|
|
// UIKit lifts the tab bar's lens all the way on a tap.
|
|
void _settle() {
|
|
if (!_releasing || _lift.value < _liftSnap) {
|
|
return;
|
|
}
|
|
_releasing = false;
|
|
_lift.springTo(0, _pressSpring);
|
|
}
|
|
|
|
Offset _localOf(Offset position) =>
|
|
(context.findRenderObject()! as RenderBox).globalToLocal(position);
|
|
|
|
@override
|
|
void press(Offset position) {
|
|
if (!widget.enabled) {
|
|
return;
|
|
}
|
|
_swell(_localOf(position));
|
|
}
|
|
|
|
@override
|
|
void drag(Offset position) => _pullToward(_localOf(position));
|
|
|
|
@override
|
|
void release() => _release();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Listener(
|
|
onPointerDown: _handlePointerDown,
|
|
onPointerMove: _handlePointerMove,
|
|
onPointerUp: _handlePointerEnd,
|
|
onPointerCancel: _handlePointerEnd,
|
|
child: AnimatedBuilder(
|
|
animation: _motion,
|
|
builder: (_, child) => _PressTransform(
|
|
lift: _lift.value,
|
|
pull: Offset(_pullX.value, _pullY.value),
|
|
maxGrowth: widget.maxGrowth,
|
|
panelOf: widget.panelOf,
|
|
child: child,
|
|
),
|
|
child: widget.child,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Leaves a press on the filled button inside to [ElasticPress]; one whose
|
|
/// style sets a foreground color merges [ElasticPress.buttonStyle].
|
|
class ElasticButton extends StatelessWidget {
|
|
const ElasticButton({super.key, this.enabled = true, required this.child});
|
|
|
|
final bool enabled;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return ElasticPress(
|
|
enabled: enabled,
|
|
child: Theme(
|
|
data: theme.copyWith(
|
|
splashFactory: NoSplash.splashFactory,
|
|
highlightColor: Colors.transparent,
|
|
),
|
|
child: IconTheme(
|
|
data: IconTheme.of(context),
|
|
child: IconButtonTheme(
|
|
data: IconButtonThemeData(
|
|
style: ElasticPress.buttonStyle.merge(
|
|
IconButtonTheme.of(context).style,
|
|
),
|
|
),
|
|
child: FilledButtonTheme(
|
|
data: FilledButtonThemeData(
|
|
style: ElasticPress.buttonStyle.merge(
|
|
FilledButtonTheme.of(context).style,
|
|
),
|
|
),
|
|
child: child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PressTransform extends SingleChildRenderObjectWidget {
|
|
const _PressTransform({
|
|
required this.lift,
|
|
required this.pull,
|
|
this.maxGrowth = _maxPressGrowth,
|
|
this.panelOf,
|
|
super.child,
|
|
});
|
|
|
|
final double lift;
|
|
final Offset pull;
|
|
final double maxGrowth;
|
|
final Rect Function(RenderBox box)? panelOf;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) {
|
|
return _RenderPressTransform(lift: lift, pull: pull)
|
|
..maxGrowth = maxGrowth
|
|
..panelOf = panelOf;
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(
|
|
BuildContext context,
|
|
_RenderPressTransform renderObject,
|
|
) {
|
|
renderObject
|
|
..lift = lift
|
|
..pull = pull
|
|
..maxGrowth = maxGrowth
|
|
..panelOf = panelOf;
|
|
}
|
|
}
|
|
|
|
class _RenderPressTransform extends RenderProxyBox {
|
|
_RenderPressTransform({required double lift, required Offset pull})
|
|
: _lift = lift,
|
|
_pull = pull;
|
|
|
|
double _lift;
|
|
Offset _pull;
|
|
double maxGrowth = _maxPressGrowth;
|
|
Rect Function(RenderBox box)? panelOf;
|
|
|
|
set lift(double value) {
|
|
if (value == _lift) {
|
|
return;
|
|
}
|
|
_lift = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
set pull(Offset value) {
|
|
if (value == _pull) {
|
|
return;
|
|
}
|
|
_pull = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
Matrix4 get _transform {
|
|
final panel = panelOf?.call(this) ?? Offset.zero & size;
|
|
final swell =
|
|
1 + _lift * math.min(_pressGrowth * 2, maxGrowth / panel.longestSide);
|
|
final scaleX = swell * (1 + _pull.dx.abs() / panel.width * _pullStretch);
|
|
final scaleY = swell * (1 + _pull.dy.abs() / panel.height * _pullStretch);
|
|
final center = panel.center;
|
|
return Matrix4.diagonal3Values(scaleX, scaleY, 1)..setTranslationRaw(
|
|
center.dx * (1 - scaleX) + _pull.dx,
|
|
center.dy * (1 - scaleY) + _pull.dy,
|
|
0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
if (child == null || size.isEmpty || (_lift == 0 && _pull == .zero)) {
|
|
layer = null;
|
|
super.paint(context, offset);
|
|
return;
|
|
}
|
|
layer = context.pushTransform(
|
|
needsCompositing,
|
|
offset,
|
|
_transform,
|
|
super.paint,
|
|
oldLayer: layer is TransformLayer ? layer as TransformLayer? : null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A floating pill of destinations whose selection is a lens that springs
|
|
/// between them, after iOS's tab bar. Pressing swells the bar as
|
|
/// [ElasticPress] does and lifts the lens under the finger; dragging slides
|
|
/// the lens across the bar, selecting where it is let go, and stretches the
|
|
/// bar past either end.
|
|
class FloatingNavigationBar extends StatefulWidget {
|
|
const FloatingNavigationBar({
|
|
super.key,
|
|
required this.destinations,
|
|
required this.selectedIndex,
|
|
required this.onSelected,
|
|
});
|
|
|
|
final List<NavigationDockDestination> destinations;
|
|
final int selectedIndex;
|
|
final ValueChanged<int> onSelected;
|
|
|
|
@override
|
|
State<FloatingNavigationBar> createState() => _FloatingNavigationBarState();
|
|
}
|
|
|
|
class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
|
with TickerProviderStateMixin {
|
|
late final _Spring _lens = _Spring(this, _selectedIndex.toDouble());
|
|
late final _Spring _lift = _Spring(this, 0);
|
|
late final _Spring _hover = _Spring(this, 0);
|
|
late final _Spring _hoverShow = _Spring(this, 0);
|
|
late final _Spring _swell = _Spring(this, 0);
|
|
late final _Spring _stretch = _Spring(this, 0);
|
|
late final _Spring _jelly = _Spring(this, 0);
|
|
late final _Spring _glow = _Spring(this, 0);
|
|
late final _Spring _glowSpread = _Spring(this, 0);
|
|
final ValueNotifier<LiquidGlassGlow> _touchGlow = ValueNotifier(
|
|
LiquidGlassGlow.none,
|
|
);
|
|
Offset _glowAt = Offset.zero;
|
|
late final Listenable _barMotion = Listenable.merge([_swell, _stretch]);
|
|
late final Listenable _motion = Listenable.merge([_lens, _lift, _jelly]);
|
|
late final Listenable _hoverMotion = Listenable.merge([_hover, _hoverShow]);
|
|
final List<_Spring> _parallax = [];
|
|
bool _lifted = false;
|
|
int? _pointer;
|
|
int? _pressedIndex;
|
|
double _pressX = 0;
|
|
bool _dragging = false;
|
|
bool _dropping = false;
|
|
VelocityTracker? _tracker;
|
|
Offset? _cursor;
|
|
final ValueNotifier<double?> _hoverAt = ValueNotifier(null);
|
|
|
|
int get _lastIndex => math.max(0, widget.destinations.length - 1);
|
|
|
|
int get _selectedIndex => widget.selectedIndex.clamp(0, _lastIndex);
|
|
|
|
({double pitch, double lens, double inset}) get _layout => _dockLayout(
|
|
context.size!.width - _barPadding * 2,
|
|
widget.destinations.length,
|
|
);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_lens
|
|
..addListener(_wobble)
|
|
..addListener(_dropLift);
|
|
_lift
|
|
..addListener(_dropLift)
|
|
..addListener(_noteLift);
|
|
_glow.addListener(_shine);
|
|
_glowSpread.addListener(_shine);
|
|
_hoverAt.addListener(_leanContent);
|
|
_syncParallax();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant FloatingNavigationBar oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
_syncParallax();
|
|
_leanContent();
|
|
final rest = _lensRest;
|
|
if (_pressedIndex == null && _lens.target != rest) {
|
|
_lens.springTo(rest, _travelSpring);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
for (final parallax in _parallax) {
|
|
parallax.dispose();
|
|
}
|
|
_lens.dispose();
|
|
_lift.dispose();
|
|
_hover.dispose();
|
|
_hoverShow.dispose();
|
|
_swell.dispose();
|
|
_stretch.dispose();
|
|
_jelly.dispose();
|
|
_glow.dispose();
|
|
_glowSpread.dispose();
|
|
_touchGlow.dispose();
|
|
_hoverAt.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _wobble() {
|
|
final velocity = _lens.velocity;
|
|
final braking = math.max(0.0, -_lens.acceleration * velocity.sign);
|
|
final target =
|
|
_jellyStretch * _saturate(velocity.abs() / _jellySpeed) -
|
|
_jellySquash * _saturate(braking / _jellyBrake);
|
|
if (target != _jelly.target) {
|
|
_jelly.springTo(target, _jellySpring);
|
|
}
|
|
}
|
|
|
|
// UIKit lights the bar from the finger, washing over the rest of it, and
|
|
// spreads the light out as it fades on release.
|
|
void _shine() {
|
|
final glow = _glow.value;
|
|
_touchGlow.value = LiquidGlassGlow(
|
|
center: _glowAt,
|
|
reach: _glowReach * (1 + _glowGrowth * _glowSpread.value),
|
|
strength: _glowPeak * glow * glow,
|
|
wash: _glowWash,
|
|
);
|
|
}
|
|
|
|
void _syncParallax() {
|
|
final count = widget.destinations.length;
|
|
while (_parallax.length > count) {
|
|
_parallax.removeLast().dispose();
|
|
}
|
|
while (_parallax.length < count) {
|
|
_parallax.add(_Spring(this, 0));
|
|
}
|
|
}
|
|
|
|
void _leanContent() {
|
|
final hoverAt = _hoverAt.value;
|
|
for (final (index, parallax) in _parallax.indexed) {
|
|
final offset = hoverAt == null ? 0.0 : hoverAt - index;
|
|
final target = offset.abs() > 0.5
|
|
? 0.0
|
|
: offset * _hoverMagnet * _hoverParallax;
|
|
if (target != parallax.target) {
|
|
parallax.springTo(target, _hoverSpring);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _noteLift() {
|
|
final lifted = _lift.value > 0;
|
|
if (lifted != _lifted) {
|
|
setState(() => _lifted = lifted);
|
|
}
|
|
}
|
|
|
|
// UIKit keeps the lens lifted until it has risen all the way and arrived.
|
|
void _dropLift() {
|
|
if (!_dropping || _pointer != null || _lift.value < _liftSnap) {
|
|
return;
|
|
}
|
|
if ((_lens.target - _lens.value).abs() > _liftRelease) {
|
|
return;
|
|
}
|
|
_dropping = false;
|
|
_lift.springTo(0, _liftSpring);
|
|
}
|
|
|
|
double _positionAt(Offset localPosition) {
|
|
final width = context.size!.width;
|
|
final dx = Directionality.of(context) == TextDirection.ltr
|
|
? localPosition.dx
|
|
: width - localPosition.dx;
|
|
final layout = _layout;
|
|
final position = (dx - _barPadding - layout.inset) / layout.pitch - 0.5;
|
|
if (position < 0) {
|
|
return _rubberBand(position, _overdrag);
|
|
}
|
|
if (position > _lastIndex) {
|
|
return _lastIndex + _rubberBand(position - _lastIndex, _overdrag);
|
|
}
|
|
return position;
|
|
}
|
|
|
|
int _indexAt(double position) => position.round().clamp(0, _lastIndex);
|
|
|
|
double _leanAt(double position) {
|
|
final index = _indexAt(position);
|
|
return index + (position - index) * _hoverMagnet;
|
|
}
|
|
|
|
double get _lensRest {
|
|
final hoverAt = _hoverAt.value;
|
|
if (hoverAt == null || _indexAt(hoverAt) != _selectedIndex) {
|
|
return _selectedIndex.toDouble();
|
|
}
|
|
return _leanAt(hoverAt);
|
|
}
|
|
|
|
// A lens headed to a just-tapped destination is left for didUpdateWidget.
|
|
void _leanLens() {
|
|
if (_pressedIndex != null || _indexAt(_lens.target) != _selectedIndex) {
|
|
return;
|
|
}
|
|
final rest = _lensRest;
|
|
if (_lens.target != rest) {
|
|
_lens.springTo(rest, _hoverSpring);
|
|
}
|
|
}
|
|
|
|
void _showHover() {
|
|
final cursor = _cursor;
|
|
if (cursor == null || _pointer != null || widget.destinations.isEmpty) {
|
|
return;
|
|
}
|
|
final position = _positionAt(cursor);
|
|
final target = _leanAt(position);
|
|
_hoverAt.value = position;
|
|
if (_hoverShow.target == 0 && _hoverShow.value == 0) {
|
|
_hover.jumpTo(target);
|
|
} else {
|
|
_hover.springTo(target, _hoverSpring);
|
|
}
|
|
_hoverShow.springTo(1, _fadeSpring);
|
|
_leanLens();
|
|
}
|
|
|
|
void _settleSwell(SpringDescription spring) {
|
|
final target = _cursor == null ? 0.0 : _hoverSwell;
|
|
if (_pointer == null && _swell.target != target) {
|
|
_swell.springTo(target, spring);
|
|
}
|
|
}
|
|
|
|
void _handleHover(PointerHoverEvent event) {
|
|
// The engine synthesizes a hover before a touch lands, and the touch
|
|
// pointer lingers until removed, so a tap would leave the highlight on.
|
|
if (event.kind == PointerDeviceKind.touch) {
|
|
return;
|
|
}
|
|
_cursor = event.localPosition;
|
|
_showHover();
|
|
_settleSwell(_settleSpring);
|
|
}
|
|
|
|
void _handleExit(PointerExitEvent event) {
|
|
_cursor = null;
|
|
_hoverShow.springTo(0, _fadeSpring);
|
|
_hoverAt.value = null;
|
|
_leanLens();
|
|
_settleSwell(_settleSpring);
|
|
}
|
|
|
|
void _handlePointerDown(PointerDownEvent event) {
|
|
if (_pointer != null || event.buttons & kPrimaryButton == 0) {
|
|
return;
|
|
}
|
|
_pointer = event.pointer;
|
|
_tracker = VelocityTracker.withKind(event.kind)
|
|
..addPosition(event.timeStamp, event.localPosition);
|
|
_hoverShow.springTo(0, _fadeSpring);
|
|
_hoverAt.value = null;
|
|
_swell.springTo(1, _pressSpring);
|
|
_glowAt = event.localPosition;
|
|
_glowSpread.jumpTo(0);
|
|
_glow.springTo(1, _glowInSpring);
|
|
_press(event.localPosition);
|
|
}
|
|
|
|
void _handlePointerMove(PointerMoveEvent event) {
|
|
if (event.pointer == _pointer) {
|
|
_tracker?.addPosition(event.timeStamp, event.localPosition);
|
|
_glowAt = event.localPosition;
|
|
_shine();
|
|
_slide(event.localPosition);
|
|
}
|
|
}
|
|
|
|
void _handlePointerEnd(PointerEvent event) {
|
|
if (event.pointer != _pointer) {
|
|
return;
|
|
}
|
|
_pointer = null;
|
|
final tracker = _tracker;
|
|
_tracker = null;
|
|
if (event is PointerUpEvent && tracker != null) {
|
|
_fling(tracker.getVelocity().pixelsPerSecond.dx);
|
|
}
|
|
_release(commit: event is PointerUpEvent);
|
|
_glow.springTo(0, _glowOutSpring);
|
|
_glowSpread.springTo(1, _glowOutSpring);
|
|
_stretch.springTo(0, _pressSpring);
|
|
if (_cursor != null) {
|
|
_cursor = event.localPosition;
|
|
_showHover();
|
|
}
|
|
_settleSwell(_pressSpring);
|
|
}
|
|
|
|
void _press(Offset localPosition) {
|
|
if (widget.destinations.isEmpty) {
|
|
return;
|
|
}
|
|
final index = _indexAt(_positionAt(localPosition));
|
|
_pressedIndex = index;
|
|
_pressX = localPosition.dx;
|
|
_dragging = false;
|
|
_dropping = false;
|
|
_lift.springTo(1, _liftSpring);
|
|
_lens.springTo(index.toDouble(), _travelSpring);
|
|
}
|
|
|
|
void _slide(Offset localPosition) {
|
|
if (_pressedIndex == null) {
|
|
return;
|
|
}
|
|
if (!_dragging) {
|
|
if ((localPosition.dx - _pressX).abs() < kTouchSlop) {
|
|
return;
|
|
}
|
|
_dragging = true;
|
|
}
|
|
final size = context.size!;
|
|
final overshoot =
|
|
localPosition.dx - localPosition.dx.clamp(0.0, size.width);
|
|
_stretch.springTo(
|
|
_rubberBand(overshoot, size.shortestSide * _pullLimit),
|
|
_trackSpring,
|
|
);
|
|
final position = _positionAt(localPosition);
|
|
_lens.springTo(position, _followSpring);
|
|
final index = _indexAt(position);
|
|
if (index != _pressedIndex) {
|
|
HapticFeedback.selectionClick();
|
|
_pressedIndex = index;
|
|
}
|
|
}
|
|
|
|
/// Carries a drag let go mid-flick on to the next destination, at most one
|
|
/// past the one under the finger.
|
|
void _fling(double velocityX) {
|
|
final pressed = _pressedIndex;
|
|
if (!_dragging || pressed == null) {
|
|
return;
|
|
}
|
|
final extent = _layout.pitch;
|
|
final direction = Directionality.of(context) == TextDirection.ltr ? 1 : -1;
|
|
final lead =
|
|
velocityX /
|
|
extent *
|
|
direction *
|
|
_flingProjection.inMicroseconds /
|
|
Duration.microsecondsPerSecond;
|
|
final index = (_lens.target + lead)
|
|
.round()
|
|
.clamp(pressed - 1, pressed + 1)
|
|
.clamp(0, _lastIndex);
|
|
if (index != pressed) {
|
|
HapticFeedback.selectionClick();
|
|
_pressedIndex = index;
|
|
}
|
|
}
|
|
|
|
void _release({required bool commit}) {
|
|
final index = _pressedIndex;
|
|
if (index == null) {
|
|
return;
|
|
}
|
|
_pressedIndex = null;
|
|
_dragging = false;
|
|
_dropping = true;
|
|
_lens.springTo((commit ? index : _selectedIndex).toDouble(), _travelSpring);
|
|
_dropLift();
|
|
if (commit && index != widget.selectedIndex) {
|
|
widget.onSelected(index);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = context.colorScheme;
|
|
final labelStyle = AppTextStyle.custom(
|
|
context,
|
|
size: _labelSize,
|
|
leading: _labelLeading,
|
|
weight: FontWeight.w500,
|
|
);
|
|
final labels = [
|
|
for (final destination in widget.destinations)
|
|
_measureLabel(context, destination.label, labelStyle),
|
|
];
|
|
final widest = labels.fold(
|
|
0.0,
|
|
(width, label) => math.max(width, label.width),
|
|
);
|
|
final lineHeight = labels.fold(
|
|
0.0,
|
|
(height, label) => math.max(height, label.height),
|
|
);
|
|
final glass = LiquidGlassScope.of(context);
|
|
final ultra =
|
|
LiquidGlassScope.qualityOf(context) == LiquidGlassQuality.ultra;
|
|
final bar = _DockSurface(
|
|
glass: glass,
|
|
style: LiquidGlassStyle.pill(colorScheme),
|
|
shadows: ultra ? dockGlassShadows(colorScheme) : const [],
|
|
glow: _touchGlow,
|
|
child: Material(
|
|
color: glass
|
|
? Colors.transparent
|
|
: SurfaceLevel.floatingOf(colorScheme).colorOf(colorScheme),
|
|
shape: AppShape.full,
|
|
// Raw pointers rather than recognizers: a drag has nothing to win the
|
|
// arena from, and a tap waiting on one lifts the lens late.
|
|
child: Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerDown: _handlePointerDown,
|
|
onPointerMove: _handlePointerMove,
|
|
onPointerUp: _handlePointerEnd,
|
|
onPointerCancel: _handlePointerEnd,
|
|
child: MouseRegion(
|
|
onHover: _handleHover,
|
|
onExit: _handleExit,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(_barPadding),
|
|
// Above the LayoutBuilder: rebuilding anything under one relays
|
|
// it out, and that repaint would otherwise reach the whole dock.
|
|
child: RepaintBoundary(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final layout = _dockLayout(
|
|
constraints.maxWidth,
|
|
widget.destinations.length,
|
|
);
|
|
final room = layout.pitch - _labelInset * 2;
|
|
final labelScale = widest <= room
|
|
? 1.0
|
|
: math.max(room / widest, _minLabelSize / _labelSize);
|
|
Rect lensRect() => _lensRect(
|
|
position: _lens.value,
|
|
stretch: _jelly.value,
|
|
pitch: layout.pitch,
|
|
width: layout.lens,
|
|
height: constraints.maxHeight,
|
|
lift: _lift.value,
|
|
).shift(Offset(layout.inset, 0));
|
|
Widget items(_LensPart part) => Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: layout.inset),
|
|
child: Row(
|
|
children: [
|
|
for (final (index, destination)
|
|
in widget.destinations.indexed)
|
|
Expanded(
|
|
child: _FloatingBarItem(
|
|
destination: destination,
|
|
selected: index == _selectedIndex,
|
|
index: index,
|
|
part: part,
|
|
lens: _lens,
|
|
lensWidth: layout.lens,
|
|
jelly: _jelly,
|
|
parallax: _parallax[index],
|
|
lift: _lift,
|
|
labelStyle: labelStyle.copyWith(
|
|
fontSize: _labelSize * labelScale,
|
|
),
|
|
labelHeight: lineHeight,
|
|
labelOverflows:
|
|
labels[index].width * labelScale > room,
|
|
onActivate: () => widget.onSelected(index),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final lensed =
|
|
glass && _lifted && widget.destinations.isNotEmpty;
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
if (widget.destinations.isNotEmpty)
|
|
AnimatedBuilder(
|
|
animation: _motion,
|
|
builder: (_, _) => _Lens(
|
|
rect: lensRect(),
|
|
lift: _lift.value,
|
|
glass: glass,
|
|
),
|
|
),
|
|
if (widget.destinations.isNotEmpty)
|
|
AnimatedBuilder(
|
|
animation: _hoverMotion,
|
|
builder: (_, _) => _HoverHighlight(
|
|
start:
|
|
layout.inset +
|
|
(_hover.value + 0.5) * layout.pitch -
|
|
layout.lens / 2,
|
|
width: layout.lens,
|
|
opacity: _hoverShow.value,
|
|
),
|
|
),
|
|
if (glass && widget.destinations.isNotEmpty)
|
|
AnimatedBuilder(
|
|
animation: _motion,
|
|
builder: (_, _) =>
|
|
_GlassLens(rect: lensRect(), lift: _lift.value),
|
|
),
|
|
items(lensed ? _LensPart.outside : _LensPart.both),
|
|
if (lensed)
|
|
AnimatedBuilder(
|
|
animation: _motion,
|
|
builder: (_, child) => _LensContent(
|
|
rect: lensRect(),
|
|
lift: _lift.value,
|
|
warp: ultra,
|
|
child: child!,
|
|
),
|
|
child: items(_LensPart.inside),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return AnimatedBuilder(
|
|
animation: _barMotion,
|
|
builder: (_, child) => _PressTransform(
|
|
lift: _swell.value,
|
|
pull: Offset(_stretch.value, 0),
|
|
child: child,
|
|
),
|
|
child: bar,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The pointer's highlight, drawn to the destination under it and leaning a
|
|
/// little toward the cursor, as iPadOS highlights a tab bar item; the item
|
|
/// under it follows the cursor half as far.
|
|
class _HoverHighlight extends StatelessWidget {
|
|
const _HoverHighlight({
|
|
required this.start,
|
|
required this.width,
|
|
required this.opacity,
|
|
});
|
|
|
|
final double start;
|
|
final double width;
|
|
final double opacity;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final alpha = _hoverAlpha * opacity.clamp(0.0, 1.0);
|
|
if (alpha == 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return Positioned.directional(
|
|
textDirection: Directionality.of(context),
|
|
start: start,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: width,
|
|
child: DecoratedBox(
|
|
decoration: ShapeDecoration(
|
|
color: context.colorScheme.onSurface.withValues(alpha: alpha),
|
|
shape: AppShape.full,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Lens extends StatelessWidget {
|
|
const _Lens({required this.rect, required this.lift, required this.glass});
|
|
|
|
final Rect rect;
|
|
final double lift;
|
|
|
|
/// On glass the lens rests as a platter in the secondary hue, sunk in dark
|
|
/// mode as iOS's tab bar marks its selection, and clears as it lifts into
|
|
/// the [_GlassLens].
|
|
final bool glass;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = context.colorScheme;
|
|
final lifted = lift.clamp(0.0, 1.0);
|
|
final tone = colorScheme.brightness == Brightness.dark ? 4.0 : 90.0;
|
|
final hct = Hct.fromInt(colorScheme.secondary.toARGB32())..tone = tone;
|
|
final platter = Color(hct.toInt()).withValues(alpha: 0.85);
|
|
return PositionedDirectional(
|
|
start: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
child: DecoratedBox(
|
|
decoration: ShapeDecoration(
|
|
color: glass
|
|
? platter.withValues(alpha: platter.a * (1 - lifted))
|
|
: Color.alphaBlend(
|
|
colorScheme.onSurface.withValues(alpha: 0.08 * lifted),
|
|
SurfaceLevel.floatingOf(
|
|
colorScheme,
|
|
).next.colorOf(colorScheme),
|
|
),
|
|
shape: AppShape.full,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The clear lens a lifted selection becomes on glass. The destinations stand
|
|
/// on it, as iOS draws them over its lens, so it bends only the bar.
|
|
class _GlassLens extends StatelessWidget {
|
|
const _GlassLens({required this.rect, required this.lift});
|
|
|
|
final Rect rect;
|
|
final double lift;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (lift <= 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final colorScheme = context.colorScheme;
|
|
return Positioned.directional(
|
|
textDirection: Directionality.of(context),
|
|
start: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
child: IgnorePointer(
|
|
child: LiquidGlass(
|
|
style: LiquidGlassStyle.lens(colorScheme).scale(lift),
|
|
shadows: _lensShadows(colorScheme, lift),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
List<BoxShadow> _lensShadows(ColorScheme colorScheme, double lift) {
|
|
final strength = colorScheme.brightness == Brightness.dark ? 3.0 : 1.0;
|
|
return [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(
|
|
alpha: 0.055 * strength * lift.clamp(0.0, 1.0),
|
|
),
|
|
blurRadius: 5,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
];
|
|
}
|
|
|
|
/// What a lifted lens on glass shows of the destinations under it, over the
|
|
/// [_GlassLens], with a pane of its own that warps only them: they are drawn
|
|
/// offscreen, so that pane reads them and not the bar.
|
|
class _LensContent extends StatelessWidget {
|
|
const _LensContent({
|
|
required this.rect,
|
|
required this.lift,
|
|
required this.warp,
|
|
required this.child,
|
|
});
|
|
|
|
final Rect rect;
|
|
final double lift;
|
|
final bool warp;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final textDirection = Directionality.of(context);
|
|
final clipper = _LensClip(rect, textDirection);
|
|
if (!warp) {
|
|
return Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: ExcludeSemantics(
|
|
child: ClipPath(clipper: clipper, child: child),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Positioned.fill(
|
|
child: IgnorePointer(
|
|
child: ExcludeSemantics(
|
|
child: ClipPath(
|
|
clipper: clipper,
|
|
clipBehavior: Clip.antiAliasWithSaveLayer,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(child: child),
|
|
Positioned.directional(
|
|
textDirection: textDirection,
|
|
start: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
child: LiquidGlass(
|
|
style: LiquidGlassStyle.lensContent(
|
|
context.colorScheme,
|
|
).scale(lift),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LensClip extends CustomClipper<Path> {
|
|
const _LensClip(this.rect, this.textDirection);
|
|
|
|
final Rect rect;
|
|
final TextDirection textDirection;
|
|
|
|
@override
|
|
Path getClip(Size size) => AppShape.full.getOuterPath(
|
|
textDirection == TextDirection.ltr
|
|
? rect
|
|
: Rect.fromLTRB(
|
|
size.width - rect.right,
|
|
rect.top,
|
|
size.width - rect.left,
|
|
rect.bottom,
|
|
),
|
|
);
|
|
|
|
@override
|
|
bool shouldReclip(_LensClip oldClipper) =>
|
|
rect != oldClipper.rect || textDirection != oldClipper.textDirection;
|
|
}
|
|
|
|
enum _LensPart { both, outside, inside }
|
|
|
|
class _FloatingBarItem extends StatefulWidget {
|
|
const _FloatingBarItem({
|
|
required this.destination,
|
|
required this.selected,
|
|
required this.index,
|
|
required this.part,
|
|
required this.lens,
|
|
required this.lensWidth,
|
|
required this.jelly,
|
|
required this.parallax,
|
|
required this.lift,
|
|
required this.labelStyle,
|
|
required this.labelHeight,
|
|
required this.labelOverflows,
|
|
required this.onActivate,
|
|
});
|
|
|
|
final NavigationDockDestination destination;
|
|
final bool selected;
|
|
final int index;
|
|
|
|
/// Only [_LensPart.inside] is drawn bare: it stands over the lens, while
|
|
/// the destination it copies takes the presses and the focus.
|
|
final _LensPart part;
|
|
final _Spring lens;
|
|
final double lensWidth;
|
|
final ValueListenable<double> jelly;
|
|
final ValueListenable<double> parallax;
|
|
final ValueListenable<double> lift;
|
|
final TextStyle? labelStyle;
|
|
final double labelHeight;
|
|
final bool labelOverflows;
|
|
final VoidCallback onActivate;
|
|
|
|
@override
|
|
State<_FloatingBarItem> createState() => _FloatingBarItemState();
|
|
}
|
|
|
|
class _FloatingBarItemState extends State<_FloatingBarItem> {
|
|
late final Map<Type, Action<Intent>> _actions = {
|
|
ActivateIntent: CallbackAction<ActivateIntent>(
|
|
onInvoke: (_) {
|
|
widget.onActivate();
|
|
return null;
|
|
},
|
|
),
|
|
};
|
|
bool _focused = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = context.colorScheme;
|
|
final color = colorScheme.onSurfaceVariant;
|
|
var tint = colorScheme.primary;
|
|
if (widget.part == _LensPart.inside &&
|
|
LiquidGlassScope.qualityOf(context) == LiquidGlassQuality.standard) {
|
|
// Without a pane of its own the copy takes the light that one adds.
|
|
final brightness = LiquidGlassStyle.lensContent(colorScheme).brightness;
|
|
tint = Color.from(
|
|
alpha: tint.a,
|
|
red: math.min(tint.r * brightness, 1),
|
|
green: math.min(tint.g * brightness, 1),
|
|
blue: math.min(tint.b * brightness, 1),
|
|
);
|
|
}
|
|
final content = _LensTint(
|
|
part: widget.part,
|
|
lens: widget.lens,
|
|
lensWidth: widget.lensWidth,
|
|
jelly: widget.jelly,
|
|
lift: widget.lift,
|
|
parallax: widget.parallax,
|
|
index: widget.index,
|
|
color: tint,
|
|
textDirection: Directionality.of(context),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: _labelInset),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
GlyphIcon(
|
|
widget.destination.glyph,
|
|
size: _iconSize,
|
|
color: color,
|
|
fill: 1,
|
|
),
|
|
const SizedBox(height: _labelGap),
|
|
SizedBox(
|
|
height: widget.labelHeight,
|
|
child: Center(
|
|
child: Text(
|
|
widget.destination.label,
|
|
maxLines: 1,
|
|
softWrap: false,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: widget.labelStyle?.copyWith(color: color),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (widget.part == _LensPart.inside) {
|
|
return content;
|
|
}
|
|
return FocusableActionDetector(
|
|
actions: _actions,
|
|
mouseCursor: SystemMouseCursors.click,
|
|
onShowFocusHighlight: (value) => setState(() => _focused = value),
|
|
child: Semantics(
|
|
container: true,
|
|
button: true,
|
|
selected: widget.selected,
|
|
label: widget.destination.label,
|
|
excludeSemantics: true,
|
|
onTap: widget.onActivate,
|
|
child: DecoratedBox(
|
|
decoration: ShapeDecoration(
|
|
color: _focused
|
|
? colorScheme.onSurface.withValues(alpha: 0.1)
|
|
: Colors.transparent,
|
|
shape: AppShape.full.copyWith(
|
|
side: _focused ? focusRingSide(colorScheme) : BorderSide.none,
|
|
),
|
|
),
|
|
child: widget.labelOverflows
|
|
? Tooltip(message: widget.destination.label, child: content)
|
|
: content,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Paints what lies under the lens in [color], magnified as the lens lifts,
|
|
/// and the rest as it is, cut to the lens's own shape so a destination it
|
|
/// passes over turns part by part. The content leans after the [parallax], in
|
|
/// destinations, while layout and hit testing see it at rest.
|
|
class _LensTint extends SingleChildRenderObjectWidget {
|
|
const _LensTint({
|
|
required this.part,
|
|
required this.lens,
|
|
required this.lensWidth,
|
|
required this.jelly,
|
|
required this.lift,
|
|
required this.parallax,
|
|
required this.index,
|
|
required this.color,
|
|
required this.textDirection,
|
|
super.child,
|
|
});
|
|
|
|
final _LensPart part;
|
|
final _Spring lens;
|
|
final double lensWidth;
|
|
final ValueListenable<double> jelly;
|
|
final ValueListenable<double> lift;
|
|
final ValueListenable<double> parallax;
|
|
final int index;
|
|
final Color color;
|
|
final TextDirection textDirection;
|
|
|
|
@override
|
|
RenderObject createRenderObject(BuildContext context) {
|
|
return _RenderLensTint(
|
|
part: part,
|
|
lens: lens,
|
|
lensWidth: lensWidth,
|
|
jelly: jelly,
|
|
lift: lift,
|
|
parallax: parallax,
|
|
index: index,
|
|
color: color,
|
|
textDirection: textDirection,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(BuildContext context, _RenderLensTint renderObject) {
|
|
renderObject
|
|
..part = part
|
|
..lens = lens
|
|
..lensWidth = lensWidth
|
|
..jelly = jelly
|
|
..lift = lift
|
|
..parallax = parallax
|
|
..index = index
|
|
..color = color
|
|
..textDirection = textDirection;
|
|
}
|
|
}
|
|
|
|
class _RenderLensTint extends RenderProxyBox {
|
|
_RenderLensTint({
|
|
required _LensPart part,
|
|
required _Spring lens,
|
|
required double lensWidth,
|
|
required ValueListenable<double> jelly,
|
|
required ValueListenable<double> lift,
|
|
required ValueListenable<double> parallax,
|
|
required int index,
|
|
required Color color,
|
|
required TextDirection textDirection,
|
|
}) : _part = part,
|
|
_lens = lens,
|
|
_lensWidth = lensWidth,
|
|
_jelly = jelly,
|
|
_lift = lift,
|
|
_parallax = parallax,
|
|
_index = index,
|
|
_color = color,
|
|
_textDirection = textDirection;
|
|
|
|
final _outside = LayerHandle<ClipPathLayer>();
|
|
final _inside = LayerHandle<ClipPathLayer>();
|
|
final _tint = LayerHandle<ColorFilterLayer>();
|
|
final _outsideMotion = LayerHandle<TransformLayer>();
|
|
final _insideMotion = LayerHandle<TransformLayer>();
|
|
|
|
_LensPart _part;
|
|
set part(_LensPart value) {
|
|
if (value == _part) {
|
|
return;
|
|
}
|
|
_part = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
_Spring _lens;
|
|
set lens(_Spring value) {
|
|
if (value == _lens) {
|
|
return;
|
|
}
|
|
if (attached) {
|
|
_lens.removeListener(markNeedsPaint);
|
|
value.addListener(markNeedsPaint);
|
|
}
|
|
_lens = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
double _lensWidth;
|
|
set lensWidth(double value) {
|
|
if (value == _lensWidth) {
|
|
return;
|
|
}
|
|
_lensWidth = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
ValueListenable<double> _jelly;
|
|
set jelly(ValueListenable<double> value) {
|
|
if (value == _jelly) {
|
|
return;
|
|
}
|
|
if (attached) {
|
|
_jelly.removeListener(markNeedsPaint);
|
|
value.addListener(markNeedsPaint);
|
|
}
|
|
_jelly = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
ValueListenable<double> _lift;
|
|
set lift(ValueListenable<double> value) {
|
|
if (value == _lift) {
|
|
return;
|
|
}
|
|
if (attached) {
|
|
_lift.removeListener(markNeedsPaint);
|
|
value.addListener(markNeedsPaint);
|
|
}
|
|
_lift = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
ValueListenable<double> _parallax;
|
|
set parallax(ValueListenable<double> value) {
|
|
if (value == _parallax) {
|
|
return;
|
|
}
|
|
if (attached) {
|
|
_parallax.removeListener(markNeedsPaint);
|
|
value.addListener(markNeedsPaint);
|
|
}
|
|
_parallax = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
int _index;
|
|
set index(int value) {
|
|
if (value == _index) {
|
|
return;
|
|
}
|
|
_index = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
Color _color;
|
|
set color(Color value) {
|
|
if (value == _color) {
|
|
return;
|
|
}
|
|
_color = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
TextDirection _textDirection;
|
|
set textDirection(TextDirection value) {
|
|
if (value == _textDirection) {
|
|
return;
|
|
}
|
|
_textDirection = value;
|
|
markNeedsPaint();
|
|
}
|
|
|
|
@override
|
|
void attach(PipelineOwner owner) {
|
|
super.attach(owner);
|
|
_lens.addListener(markNeedsPaint);
|
|
_jelly.addListener(markNeedsPaint);
|
|
_lift.addListener(markNeedsPaint);
|
|
_parallax.addListener(markNeedsPaint);
|
|
}
|
|
|
|
@override
|
|
void detach() {
|
|
_lens.removeListener(markNeedsPaint);
|
|
_jelly.removeListener(markNeedsPaint);
|
|
_lift.removeListener(markNeedsPaint);
|
|
_parallax.removeListener(markNeedsPaint);
|
|
super.detach();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_outside.layer = null;
|
|
_inside.layer = null;
|
|
_tint.layer = null;
|
|
_outsideMotion.layer = null;
|
|
_insideMotion.layer = null;
|
|
super.dispose();
|
|
}
|
|
|
|
Rect get _lensBounds {
|
|
final rect = _lensRect(
|
|
position: _lens.value - _index,
|
|
stretch: _jelly.value,
|
|
pitch: size.width,
|
|
width: _lensWidth,
|
|
height: size.height,
|
|
lift: _lift.value,
|
|
);
|
|
return _textDirection == TextDirection.ltr
|
|
? rect
|
|
: Rect.fromLTRB(
|
|
size.width - rect.right,
|
|
rect.top,
|
|
size.width - rect.left,
|
|
rect.bottom,
|
|
);
|
|
}
|
|
|
|
Matrix4? _motion(double scale) {
|
|
final direction = _textDirection == TextDirection.ltr ? 1 : -1;
|
|
final shift = _parallax.value * size.width * direction;
|
|
if (scale == 1 && shift == 0) {
|
|
return null;
|
|
}
|
|
final center = size.center(Offset.zero);
|
|
return Matrix4.diagonal3Values(scale, scale, 1)..setTranslationRaw(
|
|
center.dx * (1 - scale) + shift,
|
|
center.dy * (1 - scale),
|
|
0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void applyPaintTransform(RenderBox child, Matrix4 transform) {
|
|
final motion = _motion(1);
|
|
if (motion != null) {
|
|
transform.multiply(motion);
|
|
}
|
|
}
|
|
|
|
void _paintMoved(
|
|
PaintingContext context,
|
|
Offset offset,
|
|
Matrix4? motion,
|
|
LayerHandle<TransformLayer> handle,
|
|
) {
|
|
if (motion == null) {
|
|
handle.layer = null;
|
|
super.paint(context, offset);
|
|
return;
|
|
}
|
|
handle.layer = context.pushTransform(
|
|
needsCompositing,
|
|
offset,
|
|
motion,
|
|
super.paint,
|
|
oldLayer: handle.layer,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
if (child == null) {
|
|
return;
|
|
}
|
|
final rest = _motion(1);
|
|
final magnified = _motion(1 + _lensMagnify * _lift.value);
|
|
final bounds = Offset.zero & size;
|
|
Rect moved(Matrix4? motion) =>
|
|
motion == null ? bounds : MatrixUtils.transformRect(motion, bounds);
|
|
final lensBounds = _lensBounds;
|
|
// The lens rests [_barPadding] past its destination, over the edges of
|
|
// the neighbours where nothing is drawn.
|
|
final reached = Rect.fromLTRB(
|
|
lensBounds.left + _barPadding,
|
|
lensBounds.top,
|
|
lensBounds.right - _barPadding,
|
|
lensBounds.bottom,
|
|
);
|
|
final outside = _part != _LensPart.inside;
|
|
final inside = _part != _LensPart.outside;
|
|
if (!reached.overlaps(moved(rest)) && !reached.overlaps(moved(magnified))) {
|
|
_outside.layer = null;
|
|
_inside.layer = null;
|
|
_tint.layer = null;
|
|
_insideMotion.layer = null;
|
|
if (outside) {
|
|
_paintMoved(context, offset, rest, _outsideMotion);
|
|
} else {
|
|
_outsideMotion.layer = null;
|
|
}
|
|
return;
|
|
}
|
|
final reach = moved(rest).expandToInclude(bounds).inflate(_labelInset);
|
|
final lens = AppShape.full.getOuterPath(lensBounds);
|
|
if (outside) {
|
|
_outside.layer = context.pushClipPath(
|
|
needsCompositing,
|
|
offset,
|
|
reach,
|
|
Path()
|
|
..fillType = PathFillType.evenOdd
|
|
..addRect(reach)
|
|
..addPath(lens, Offset.zero),
|
|
(context, offset) => _paintMoved(context, offset, rest, _outsideMotion),
|
|
oldLayer: _outside.layer,
|
|
);
|
|
} else {
|
|
_outside.layer = null;
|
|
_outsideMotion.layer = null;
|
|
}
|
|
if (inside) {
|
|
_inside.layer = context.pushClipPath(
|
|
needsCompositing,
|
|
offset,
|
|
lensBounds,
|
|
lens,
|
|
(context, offset) =>
|
|
_paintTinted(context, offset, lensBounds, magnified),
|
|
oldLayer: _inside.layer,
|
|
);
|
|
} else {
|
|
_inside.layer = null;
|
|
_tint.layer = null;
|
|
_insideMotion.layer = null;
|
|
}
|
|
}
|
|
|
|
void _paintTinted(
|
|
PaintingContext context,
|
|
Offset offset,
|
|
Rect lensBounds,
|
|
Matrix4? motion,
|
|
) {
|
|
final filter = ColorFilter.mode(_color, BlendMode.srcIn);
|
|
if (needsCompositing) {
|
|
_tint.layer = context.pushColorFilter(
|
|
offset,
|
|
filter,
|
|
(context, offset) =>
|
|
_paintMoved(context, offset, motion, _insideMotion),
|
|
oldLayer: _tint.layer,
|
|
);
|
|
return;
|
|
}
|
|
_tint.layer = null;
|
|
final canvas = context.canvas
|
|
..saveLayer(lensBounds.shift(offset), Paint()..colorFilter = filter);
|
|
_paintMoved(context, offset, motion, _insideMotion);
|
|
canvas.restore();
|
|
}
|
|
}
|