анимация circular reveal при смене темы

This commit is contained in:
klockky
2026-05-22 18:17:56 +00:00
parent 684339acff
commit c78a684036
3 changed files with 249 additions and 54 deletions
@@ -81,10 +81,11 @@ class _ThemeModeCard extends StatelessWidget {
icon: item.icon,
label: item.label,
selected: current == item.mode,
onTap: () {
onTap: (position) {
if (current == item.mode) return;
Haptics.selection();
KometApp.stateOf(context)?.applyThemeMode(item.mode);
KometApp.stateOf(context)
?.applyThemeModeWithReveal(item.mode, position);
},
),
],
@@ -98,11 +99,11 @@ class _ThemeModeCard extends StatelessWidget {
}
}
class _ModeTile extends StatelessWidget {
class _ModeTile extends StatefulWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
final ValueChanged<Offset> onTap;
const _ModeTile({
required this.icon,
@@ -111,23 +112,31 @@ class _ModeTile extends StatelessWidget {
required this.onTap,
});
@override
State<_ModeTile> createState() => _ModeTileState();
}
class _ModeTileState extends State<_ModeTile> {
Offset _lastTapPosition = Offset.zero;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onTapDown: (d) => _lastTapPosition = d.globalPosition,
onTap: () => widget.onTap(_lastTapPosition),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
children: [
Icon(icon, color: cs.onSurface, size: 22, weight: 500),
Icon(widget.icon, color: cs.onSurface, size: 22, weight: 500),
const SizedBox(width: 14),
Expanded(
child: Text(
label,
widget.label,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
@@ -136,10 +145,12 @@ class _ModeTile extends StatelessWidget {
),
),
Icon(
selected ? Symbols.radio_button_checked : Symbols.radio_button_unchecked,
color: selected ? cs.primary : cs.outline,
widget.selected
? Symbols.radio_button_checked
: Symbols.radio_button_unchecked,
color: widget.selected ? cs.primary : cs.outline,
size: 22,
fill: selected ? 1 : 0,
fill: widget.selected ? 1 : 0,
),
],
),
@@ -149,54 +160,76 @@ class _ModeTile extends StatelessWidget {
}
}
class _AmoledCard extends StatelessWidget {
class _AmoledCard extends StatefulWidget {
const _AmoledCard();
@override
State<_AmoledCard> createState() => _AmoledCardState();
}
class _AmoledCardState extends State<_AmoledCard> {
Offset _lastPointerPosition = Offset.zero;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 12, 14),
child: Row(
children: [
Icon(Symbols.contrast, color: cs.onSurface, size: 24, weight: 500),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AMOLED-чёрный',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'Чистый чёрный фон для OLED-экранов',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (e) => _lastPointerPosition = e.position,
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 12, 14),
child: Row(
children: [
Icon(
Symbols.contrast,
color: cs.onSurface,
size: 24,
weight: 500,
),
),
ValueListenableBuilder<bool>(
valueListenable: AppAmoled.current,
builder: (context, value, _) {
return Switch(
value: value,
onChanged: (v) {
Haptics.selection();
KometApp.stateOf(context)?.applyAmoled(v);
},
);
},
),
],
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AMOLED-чёрный',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'Чистый чёрный фон для OLED-экранов',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
ValueListenableBuilder<bool>(
valueListenable: AppAmoled.current,
builder: (context, value, _) {
return Switch(
value: value,
onChanged: (v) {
Haptics.selection();
KometApp.stateOf(context)?.applyAmoledWithReveal(
v,
_lastPointerPosition,
);
},
);
},
),
],
),
),
),
);
+70
View File
@@ -0,0 +1,70 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class ThemeRevealOverlay {
static OverlayEntry build({
required ui.Image snapshot,
required Offset center,
required Animation<double> animation,
}) {
return OverlayEntry(
builder: (ctx) {
final size = MediaQuery.sizeOf(ctx);
final maxRadius = _maxRadius(center, size);
return IgnorePointer(
child: AnimatedBuilder(
animation: animation,
builder: (_, __) {
final t = Curves.easeInOutCubic.transform(
animation.value.clamp(0.0, 1.0),
);
return ClipPath(
clipper: _RevealClipper(
center: center,
radius: maxRadius * t,
),
child: Opacity(
opacity: 1.0 - (t * t * t * t),
child: RawImage(
image: snapshot,
width: size.width,
height: size.height,
fit: BoxFit.fill,
),
),
);
},
),
);
},
);
}
static double _maxRadius(Offset center, Size size) {
final dx = math.max(center.dx, size.width - center.dx);
final dy = math.max(center.dy, size.height - center.dy);
return math.sqrt(dx * dx + dy * dy);
}
}
class _RevealClipper extends CustomClipper<Path> {
final Offset center;
final double radius;
_RevealClipper({required this.center, required this.radius});
@override
Path getClip(Size size) {
final full = Path()..addRect(Offset.zero & size);
if (radius <= 0) return full;
final hole = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));
return Path.combine(PathOperation.difference, full, hole);
}
@override
bool shouldReclip(covariant _RevealClipper old) =>
old.center != center || old.radius != radius;
}
+94 -2
View File
@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
@@ -31,6 +33,7 @@ import 'frontend/debug/fps_overlay_layer.dart';
import 'frontend/screens/auth/login_screen.dart';
import 'frontend/screens/chats/chat_list_screen.dart';
import 'frontend/widgets/custom_notification.dart';
import 'frontend/widgets/theme_reveal.dart';
final api = Api();
final accountModule = AccountModule(api);
@@ -128,9 +131,15 @@ class KometApp extends StatefulWidget {
State<KometApp> createState() => KometAppState();
}
class KometAppState extends State<KometApp> with WidgetsBindingObserver {
class KometAppState extends State<KometApp>
with WidgetsBindingObserver, TickerProviderStateMixin {
static const _fallbackSeed = Color(0xFFC1C4FF);
final GlobalKey _captureBoundaryKey = GlobalKey();
OverlayEntry? _revealEntry;
AnimationController? _revealController;
ui.Image? _revealImage;
late Locale _locale;
late String _fontId;
bool _isLoggingOut = false;
@@ -239,6 +248,7 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
@override
void dispose() {
_finishReveal();
_sessionExpiredSub?.cancel();
_loginStatusSub?.cancel();
_vpnBypassSub?.cancel();
@@ -321,10 +331,89 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
await AppThemeModeConfig.save(mode);
}
void applyThemeModeWithReveal(AppThemeMode mode, Offset center) {
if (AppThemeModeConfig.current.value == mode) return;
_runThemeReveal(center, () => AppThemeModeConfig.save(mode));
}
Future<void> applyAmoled(bool value) async {
await AppAmoled.save(value);
}
void applyAmoledWithReveal(bool value, Offset center) {
if (AppAmoled.current.value == value) return;
_runThemeReveal(center, () => AppAmoled.save(value));
}
void _runThemeReveal(Offset center, Future<void> Function() apply) {
final overlay = KometApp.navigatorKey.currentState?.overlay;
final ctx = _captureBoundaryKey.currentContext;
if (overlay == null || ctx == null) {
apply();
return;
}
if (MediaQuery.disableAnimationsOf(ctx)) {
apply();
return;
}
final renderObject = ctx.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
apply();
return;
}
final ui.Image snapshot;
try {
final dpr = math.min(MediaQuery.of(ctx).devicePixelRatio, 2.0);
snapshot = renderObject.toImageSync(pixelRatio: dpr);
} catch (_) {
apply();
return;
}
_finishReveal();
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
);
final entry = ThemeRevealOverlay.build(
snapshot: snapshot,
center: center,
animation: controller,
);
_revealController = controller;
_revealEntry = entry;
_revealImage = snapshot;
overlay.insert(entry);
apply();
WidgetsBinding.instance.endOfFrame.then((_) {
if (_revealController != controller) return;
controller.forward().then(
(_) {
if (_revealController != controller) return;
_finishReveal();
},
onError: (_) {},
);
});
}
void _finishReveal() {
_revealEntry?.remove();
_revealEntry = null;
_revealController?.dispose();
_revealController = null;
final img = _revealImage;
_revealImage = null;
if (img != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose());
}
}
Future<void> applyThemeSchedule(ThemeSchedule schedule) async {
await AppThemeSchedule.save(schedule);
}
@@ -553,7 +642,10 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
sChild!,
RepaintBoundary(
key: _captureBoundaryKey,
child: sChild!,
),
if (fpsOn) const FpsOverlayLayer(),
],
);