优化交互体验增加动画效果

This commit is contained in:
2026-06-08 10:58:10 +08:00
parent 6b168ccd62
commit 942d15e54c
6 changed files with 250 additions and 78 deletions

View File

@@ -8,6 +8,8 @@ import 'package:recording_tool/gen/assets.gen.dart';
class RecordDialog extends StatelessWidget { class RecordDialog extends StatelessWidget {
const RecordDialog({super.key, required this.title, required this.actions}); const RecordDialog({super.key, required this.title, required this.actions});
static const _transitionDuration = Duration(milliseconds: 280);
final String title; final String title;
final List<RecordDialogAction> actions; final List<RecordDialogAction> actions;
@@ -18,8 +20,8 @@ class RecordDialog extends StatelessWidget {
VoidCallback? onPressed, VoidCallback? onPressed,
bool barrierDismissible = true, bool barrierDismissible = true,
}) { }) {
return showDialog<void>( return _present(
context: context, context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
builder: (dialogContext) { builder: (dialogContext) {
return RecordDialog( return RecordDialog(
@@ -47,8 +49,8 @@ class RecordDialog extends StatelessWidget {
VoidCallback? onRightPressed, VoidCallback? onRightPressed,
bool barrierDismissible = false, bool barrierDismissible = false,
}) { }) {
return showDialog<void>( return _present(
context: context, context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
builder: (dialogContext) { builder: (dialogContext) {
return RecordDialog( return RecordDialog(
@@ -74,6 +76,51 @@ class RecordDialog extends StatelessWidget {
); );
} }
static Future<void> _present(
BuildContext context, {
required Widget Function(BuildContext dialogContext) builder,
required bool barrierDismissible,
}) {
return showGeneralDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black54,
transitionDuration: _transitionDuration,
pageBuilder: (dialogContext, animation, secondaryAnimation) {
return builder(dialogContext);
},
transitionBuilder: _buildTransition,
);
}
static Widget _buildTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curved),
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(curved),
child: child,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionWidgets = actions final actionWidgets = actions

View File

@@ -263,34 +263,38 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
if (!state.isPreviewReady && state.errorMessage == null) if (!state.isPreviewReady && state.errorMessage == null)
const RecordingLoadingOverlayWidget(message: '正在启动相机…'), const RecordingLoadingOverlayWidget(message: '正在启动相机…'),
const RecordTimerWidget(), const RecordTimerWidget(),
RecordingHudWidget( RepaintBoundary(
state: state, child: RecordingHudWidget(
showClipboardHint: showClipboardInfo, state: state,
clipboardAddress: clipboard.address.trim(), showClipboardHint: showClipboardInfo,
onStart: _onStartRecording, clipboardAddress: clipboard.address.trim(),
onStop: () async { onStart: _onStartRecording,
await viewModel.stopRecording(); onStop: () async {
if (!context.mounted) return; await viewModel.stopRecording();
final latest = ref if (!context.mounted) return;
.read(recordingViewModelProvider) final latest = ref
.session; .read(recordingViewModelProvider)
if (latest.gallerySaveFailed) { .session;
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); if (latest.gallerySaveFailed) {
return; AppToast.show(
} latest.errorMessage ?? '保存到相册失败,请开启相册权限',
await _showRecordingSavedDialogIfNeeded(); );
}, return;
onOpenDnd: () async { }
await viewModel.openDndSettings(); await _showRecordingSavedDialogIfNeeded();
await viewModel.refreshDndAccess(); },
}, onOpenDnd: () async {
onOpenBattery: () async { await viewModel.openDndSettings();
await viewModel.openBatterySettings(); await viewModel.refreshDndAccess();
await viewModel.refreshBatteryOptimization(); },
}, onOpenBattery: () async {
onToggleTouchLock: () { await viewModel.openBatterySettings();
viewModel.setTouchLocked(!state.isTouchLocked); await viewModel.refreshBatteryOptimization();
}, },
onToggleTouchLock: () {
viewModel.setTouchLocked(!state.isTouchLocked);
},
),
), ),
if (state.isTouchLocked && state.isRecording) if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlayWidget( RecordingTouchLockOverlayWidget(

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// 录制页内容切换时的统一过渡动画。
class RecordContentTransition {
RecordContentTransition._();
static const duration = Duration(milliseconds: 600);
static Widget builder(Widget child, Animation<double> animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(curved),
child: child,
),
);
}
static Widget stackLayoutBuilder(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
}
static Widget bottomStackLayoutBuilder(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
alignment: Alignment.bottomLeft,
clipBehavior: Clip.none,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/core/utils/date_time_formatter.dart'; import 'package:recording_tool/core/utils/date_time_formatter.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
/// 左下角实时时钟与剪贴板地址 /// 左下角实时时钟与剪贴板地址
class ClipboardAddressClockChipWidget extends StatefulWidget { class ClipboardAddressClockChipWidget extends StatefulWidget {
@@ -48,14 +49,32 @@ class _ClipboardAddressClockChipWidgetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return AnimatedSize(
crossAxisAlignment: CrossAxisAlignment.start, duration: RecordContentTransition.duration,
mainAxisSize: MainAxisSize.min, curve: Curves.easeOutCubic,
children: [ alignment: Alignment.topLeft,
Text(_nowText, style: _textStyle), clipBehavior: Clip.none,
if (widget.address.isNotEmpty) child: Column(
Text(widget.address, style: _textStyle), crossAxisAlignment: CrossAxisAlignment.start,
], mainAxisSize: MainAxisSize.min,
children: [
Text(_nowText, style: _textStyle),
AnimatedSwitcher(
duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.bottomStackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: widget.address.isNotEmpty
? Text(
widget.address,
key: ValueKey(widget.address),
style: _textStyle,
)
: const SizedBox.shrink(key: ValueKey('clipboard-address-empty')),
),
],
),
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/gen/assets.gen.dart'; import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart'; import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -27,6 +28,27 @@ class RecordHeaderWidget extends StatelessWidget {
bool get _showEventTitle => hasValidClipboardInfo; bool get _showEventTitle => hasValidClipboardInfo;
Widget _buildHeaderContent() {
if (_showEventTitle) {
return _HeaderEventTitleRow(
key: ValueKey('title-${eventTitle ?? ''}'),
title: eventTitle ?? '',
isRecording: isRecording,
onClearEventInfo: onClearEventInfo,
);
}
if (_showPasteButtons) {
return _HeaderPasteActions(
key: const ValueKey('paste-actions'),
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
);
}
return const SizedBox.shrink(key: ValueKey('header-empty'));
}
void _mockCopyEventInfo() { void _mockCopyEventInfo() {
const strTemp = const strTemp =
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}'; '{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
@@ -52,18 +74,14 @@ class RecordHeaderWidget extends StatelessWidget {
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
Expanded( Expanded(
child: _showEventTitle child: AnimatedSwitcher(
? _HeaderEventTitleRow( duration: RecordContentTransition.duration,
title: eventTitle ?? '', switchInCurve: Curves.easeOutCubic,
isRecording: isRecording, switchOutCurve: Curves.easeInCubic,
onClearEventInfo: onClearEventInfo, layoutBuilder: RecordContentTransition.stackLayoutBuilder,
) transitionBuilder: RecordContentTransition.builder,
: _showPasteButtons child: _buildHeaderContent(),
? _HeaderPasteActions( ),
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
)
: const SizedBox.shrink(),
), ),
], ],
), ),
@@ -75,6 +93,7 @@ class RecordHeaderWidget extends StatelessWidget {
class _HeaderEventTitleRow extends StatelessWidget { class _HeaderEventTitleRow extends StatelessWidget {
const _HeaderEventTitleRow({ const _HeaderEventTitleRow({
super.key,
required this.title, required this.title,
required this.isRecording, required this.isRecording,
required this.onClearEventInfo, required this.onClearEventInfo,
@@ -95,30 +114,45 @@ class _HeaderEventTitleRow extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Text( child: AnimatedSwitcher(
title, duration: RecordContentTransition.duration,
style: _overlayTextStyle.copyWith( switchInCurve: Curves.easeOutCubic,
fontSize: 12.sp, switchOutCurve: Curves.easeInCubic,
fontWeight: FontWeight.w600, transitionBuilder: RecordContentTransition.builder,
child: Text(
title,
key: ValueKey(title),
style: _overlayTextStyle.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
), ),
if (!isRecording) AnimatedSwitcher(
IconButton( duration: RecordContentTransition.duration,
onPressed: onClearEventInfo, switchInCurve: Curves.easeOutCubic,
icon: Assets.images.imageDelete.image( switchOutCurve: Curves.easeInCubic,
width: 15.r, transitionBuilder: RecordContentTransition.builder,
height: 15.r, child: !isRecording
fit: BoxFit.contain, ? IconButton(
excludeFromSemantics: true, key: const ValueKey('clear-event-info'),
), onPressed: onClearEventInfo,
padding: EdgeInsets.zero, icon: Assets.images.imageDelete.image(
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r), width: 15.r,
tooltip: '删除', height: 15.r,
), fit: BoxFit.contain,
excludeFromSemantics: true,
),
padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
tooltip: '删除',
)
: const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')),
),
], ],
); );
} }
@@ -126,6 +160,7 @@ class _HeaderEventTitleRow extends StatelessWidget {
class _HeaderPasteActions extends StatelessWidget { class _HeaderPasteActions extends StatelessWidget {
const _HeaderPasteActions({ const _HeaderPasteActions({
super.key,
required this.onMockCopy, required this.onMockCopy,
required this.onPasteEventInfo, required this.onPasteEventInfo,
}); });

View File

@@ -3,6 +3,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/utils/rate_limiter.dart'; import 'package:recording_tool/core/utils/rate_limiter.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/features/recording/widgets/widget_clipboard_address_clock_chip.dart'; import 'package:recording_tool/features/recording/widgets/widget_clipboard_address_clock_chip.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart'; import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_setup_hints.dart'; import 'package:recording_tool/features/recording/widgets/widget_recording_setup_hints.dart';
@@ -84,12 +85,23 @@ class RecordingHudWidget extends StatelessWidget {
], ],
), ),
), ),
if (showClipboardHint) Positioned(
Positioned( left: _overlayInfoLeft,
left: _overlayInfoLeft, bottom: _overlayInfoBottom,
bottom: _overlayInfoBottom, child: AnimatedSwitcher(
child: ClipboardAddressClockChipWidget(address: clipboardAddress), duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.bottomStackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: showClipboardHint
? ClipboardAddressClockChipWidget(
key: const ValueKey('clipboard-info'),
address: clipboardAddress,
)
: const SizedBox.shrink(key: ValueKey('clipboard-info-hidden')),
), ),
),
if (state.isRecording) if (state.isRecording)
Positioned( Positioned(
left: 16.r, left: 16.r,