Files
FlClash/lib/ui/text_loupe.dart
chen08209 9bd2286eb5 feat(ui): layer surfaces with fills and follow the iOS type scale
Changelog: iOS-style surfaces, alerts and sheets; clearer TV focus
2026-10-17 17:21:43 +08:00

150 lines
4.5 KiB
Dart

import 'dart:math' as math;
import 'package:fl_clash/ui/shape.dart';
import 'package:flutter/foundation.dart' show ValueListenable;
import 'package:material_ui/material_ui.dart';
// Geometry and timing follow Flutter's CupertinoTextMagnifier, which mirrors
// the iOS text loupe; the capsule is drawn as a superellipse instead.
const _loupeSize = Size(80, 47.5);
const _loupeGapAboveLineCenter = 26.0;
const _loupeEdgePadding = 10.0;
const _loupeDragResistance = 10.0;
const _loupeInOutDuration = Duration(milliseconds: 150);
const _loupeFollowDuration = Duration(milliseconds: 45);
/// An iOS-style text loupe for a [MagnifierBuilder]. It animates in when
/// shown and hands its animation to [controller], so
/// [MagnifierController.hide] plays it out before the overlay entry goes.
class TextLoupe extends StatefulWidget {
final MagnifierController controller;
final ValueListenable<MagnifierInfo> magnifierInfo;
final Color borderColor;
const TextLoupe({
super.key,
required this.controller,
required this.magnifierInfo,
required this.borderColor,
});
@override
State<TextLoupe> createState() => _TextLoupeState();
}
class _TextLoupeState extends State<TextLoupe>
with SingleTickerProviderStateMixin {
late final AnimationController _inOut;
late final CurvedAnimation _inOutCurve;
Offset _position = Offset.zero;
double _focalAdjustment = 0;
@override
void initState() {
super.initState();
_inOut = AnimationController(vsync: this, duration: _loupeInOutDuration)
..forward();
_inOutCurve = CurvedAnimation(parent: _inOut, curve: Curves.easeOut);
widget.controller.animationController = _inOut;
widget.magnifierInfo.addListener(_onInfoChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_reposition();
}
@override
void didUpdateWidget(TextLoupe oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.magnifierInfo != widget.magnifierInfo) {
oldWidget.magnifierInfo.removeListener(_onInfoChanged);
widget.magnifierInfo.addListener(_onInfoChanged);
_reposition();
}
}
@override
void dispose() {
widget.magnifierInfo.removeListener(_onInfoChanged);
if (widget.controller.animationController == _inOut) {
widget.controller.animationController = null;
}
_inOutCurve.dispose();
_inOut.dispose();
super.dispose();
}
void _onInfoChanged() {
if (!widget.controller.shown) _inOut.forward();
setState(_reposition);
}
void _reposition() {
final info = widget.magnifierInfo.value;
final lineCenter = info.caretRect.center.dy;
final lensY = math.max(
lineCenter,
lineCenter -
(lineCenter - info.globalGesturePosition.dy) / _loupeDragResistance,
);
final screenWidth = MediaQuery.sizeOf(context).width;
final left = (info.globalGesturePosition.dx - _loupeSize.width / 2).clamp(
_loupeEdgePadding,
math.max(
_loupeEdgePadding,
screenWidth - _loupeEdgePadding - _loupeSize.width,
),
);
_position = Offset(
left.toDouble(),
lensY - _loupeSize.height - _loupeGapAboveLineCenter,
);
_focalAdjustment = lineCenter - lensY;
}
@override
Widget build(BuildContext context) {
return AnimatedPositioned(
duration: _loupeFollowDuration,
curve: Curves.easeOut,
left: _position.dx,
top: _position.dy,
child: AnimatedBuilder(
animation: _inOutCurve,
builder: (context, _) {
final t = _inOutCurve.value;
return Transform.translate(
offset: Offset(0, _loupeGapAboveLineCenter * (1 - t)),
child: RawMagnifier(
size: _loupeSize,
magnificationScale: 1.25,
focalPointOffset: Offset(
0,
_loupeSize.height / 2 +
_loupeGapAboveLineCenter +
_focalAdjustment,
),
decoration: MagnifierDecoration(
opacity: t,
shape: AppShape.full.copyWith(
side: BorderSide(color: widget.borderColor, width: 2),
),
shadows: const [
BoxShadow(
color: Color.fromARGB(25, 0, 0, 0),
blurRadius: 11,
spreadRadius: 0.2,
blurStyle: BlurStyle.outer,
),
],
),
),
);
},
),
);
}
}