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

This commit is contained in:
klockky
2026-05-22 18:17:56 +00:00
parent 9badd63879
commit 939dabbd86
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;
}