Commit d4d91cb0f04764f3a01d6d8138ae3a24e4ba8410
1 parent
aefec95d
feat:lottie动效组件封装&语音跟读页动效
Showing
6 changed files
with
341 additions
and
75 deletions
lib/common/widgets/record_playback_widget.dart
0 → 100644
1 | +import 'package:flutter/material.dart'; | |
2 | +import 'package:lottie/lottie.dart'; | |
3 | +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart'; | |
4 | + | |
5 | +import '../../utils/log_util.dart'; | |
6 | + | |
7 | +/// 录音组件 | |
8 | +class _RecorderPlaybackWidget extends StatefulWidget { | |
9 | + final bool isClickable; | |
10 | + final bool isPlaying; | |
11 | + final VoidCallback? onTap; | |
12 | + final double width; | |
13 | + final double height; | |
14 | + | |
15 | + const _RecorderPlaybackWidget({ | |
16 | + Key? key, | |
17 | + required this.isClickable, | |
18 | + required this.isPlaying, | |
19 | + required this.onTap, | |
20 | + required this.width, | |
21 | + required this.height, | |
22 | + }) : super(key: key); | |
23 | + | |
24 | + @override | |
25 | + __RecorderPlaybackWidgetState createState() => | |
26 | + __RecorderPlaybackWidgetState(); | |
27 | +} | |
28 | + | |
29 | +class __RecorderPlaybackWidgetState extends State<_RecorderPlaybackWidget> | |
30 | + with SingleTickerProviderStateMixin { | |
31 | + late final AnimationController _controller; | |
32 | + late final LottieComposition _composition; | |
33 | + static const String TAG = "_RecorderPlaybackWidget"; | |
34 | + | |
35 | + bool _isPlaying = false; | |
36 | + | |
37 | + @override | |
38 | + void initState() { | |
39 | + super.initState(); | |
40 | + _controller = AnimationController(vsync: this); | |
41 | + _loadComposition(); | |
42 | + | |
43 | + _controller.addListener(() { | |
44 | + // Log.d("$TAG addListener _controller=${_controller.status}"); | |
45 | + }); | |
46 | + } | |
47 | + | |
48 | + Future<void> _loadComposition() async { | |
49 | + final composition = | |
50 | + await AssetLottie('assets/lotties/recorder_back.zip').load(); | |
51 | + setState(() { | |
52 | + _composition = composition; | |
53 | + _controller.duration = _composition.duration; | |
54 | + }); | |
55 | + | |
56 | + if (widget.isPlaying) { | |
57 | + _playAnimation(); | |
58 | + } | |
59 | + } | |
60 | + | |
61 | + void _playAnimation() { | |
62 | + _controller.reset(); | |
63 | + _controller.repeat( | |
64 | + min: 2 / _composition.endFrame, | |
65 | + max: 22 / _composition.endFrame, | |
66 | + ); | |
67 | + } | |
68 | + | |
69 | + void _stopAnimation() { | |
70 | + _controller.stop(); | |
71 | + _resetAnimation(); | |
72 | + } | |
73 | + | |
74 | + void _resetAnimation() { | |
75 | + setState(() { | |
76 | + _isPlaying = false; | |
77 | + _controller.value = 1; | |
78 | + // _controller.repeat( | |
79 | + // min: 1 / _composition.endFrame, | |
80 | + // max: 1 / _composition.endFrame, | |
81 | + // ); | |
82 | + }); | |
83 | + } | |
84 | + | |
85 | + void _displayAnimation(bool clickable) { | |
86 | + if (clickable) { | |
87 | + _controller.value = 1; | |
88 | + // _controller.repeat( | |
89 | + // min: 1, | |
90 | + // max: 1, | |
91 | + // ); | |
92 | + } else { | |
93 | + _controller.value = 0; | |
94 | + // _controller.repeat( | |
95 | + // min: 0, | |
96 | + // max: 0, | |
97 | + // ); | |
98 | + } | |
99 | + } | |
100 | + | |
101 | + @override | |
102 | + void didUpdateWidget(_RecorderPlaybackWidget oldWidget) { | |
103 | + super.didUpdateWidget(oldWidget); | |
104 | + Log.d( | |
105 | + "$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying"); | |
106 | + if (widget.isPlaying && !_isPlaying) { | |
107 | + setState(() { | |
108 | + _isPlaying = true; | |
109 | + }); | |
110 | + _playAnimation(); | |
111 | + } else if (!widget.isPlaying && _isPlaying) { | |
112 | + _stopAnimation(); | |
113 | + } | |
114 | + | |
115 | + if (!_isPlaying) { | |
116 | + _displayAnimation(widget.isClickable); | |
117 | + } | |
118 | + } | |
119 | + | |
120 | + @override | |
121 | + void dispose() { | |
122 | + _controller.dispose(); | |
123 | + super.dispose(); | |
124 | + } | |
125 | + | |
126 | + @override | |
127 | + Widget build(BuildContext context) { | |
128 | + return ThrottledGestureDetector( | |
129 | + onTap: widget.isClickable ? widget.onTap : null, | |
130 | + child: Opacity( | |
131 | + opacity: widget.isClickable ? 1.0 : 0.5, // 设置透明度 | |
132 | + child: Lottie( | |
133 | + composition: _composition, | |
134 | + controller: _controller, | |
135 | + renderCache: RenderCache.raster, | |
136 | + width: widget.width, | |
137 | + height: widget.height, | |
138 | + ), | |
139 | + ), | |
140 | + ); | |
141 | + } | |
142 | +} | ... | ... |
lib/common/widgets/recorder_widget.dart
0 → 100644
1 | +import 'package:flutter/material.dart'; | |
2 | +import 'package:lottie/lottie.dart'; | |
3 | +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart'; | |
4 | + | |
5 | +import '../../utils/log_util.dart'; | |
6 | + | |
7 | +/// 录音组件 | |
8 | +class RecorderWidget extends StatefulWidget { | |
9 | + final bool isClickable; | |
10 | + final bool isPlaying; | |
11 | + final VoidCallback? onTap; | |
12 | + final double width; | |
13 | + final double height; | |
14 | + | |
15 | + const RecorderWidget({ | |
16 | + Key? key, | |
17 | + required this.isClickable, | |
18 | + required this.isPlaying, | |
19 | + required this.onTap, | |
20 | + required this.width, | |
21 | + required this.height, | |
22 | + }) : super(key: key); | |
23 | + | |
24 | + @override | |
25 | + _RecorderWidgetState createState() => _RecorderWidgetState(); | |
26 | +} | |
27 | + | |
28 | +class _RecorderWidgetState extends State<RecorderWidget> | |
29 | + with SingleTickerProviderStateMixin { | |
30 | + late final AnimationController _controller; | |
31 | + late final LottieComposition _composition; | |
32 | + static const String TAG = "RecorderWidget"; | |
33 | + | |
34 | + bool _isPlaying = false; | |
35 | + | |
36 | + @override | |
37 | + void initState() { | |
38 | + super.initState(); | |
39 | + _controller = AnimationController(vsync: this); | |
40 | + _loadComposition(); | |
41 | + | |
42 | + _controller.addListener(() { | |
43 | + // Log.d("$TAG addListener _controller=${_controller.status}"); | |
44 | + }); | |
45 | + } | |
46 | + | |
47 | + Future<void> _loadComposition() async { | |
48 | + final composition = await AssetLottie('assets/lotties/recorder_input.zip').load(); | |
49 | + setState(() { | |
50 | + _composition = composition; | |
51 | + _controller.duration = _composition.duration; | |
52 | + }); | |
53 | + | |
54 | + if (widget.isPlaying) { | |
55 | + _playInitialAnimation(); | |
56 | + } | |
57 | + } | |
58 | + | |
59 | + void _playInitialAnimation() { | |
60 | + _controller.reset(); | |
61 | + _controller | |
62 | + .animateTo(22 / _composition.endFrame) | |
63 | + .whenComplete(() => _loopMiddleAnimation()); | |
64 | + } | |
65 | + | |
66 | + void _loopMiddleAnimation() { | |
67 | + _controller.repeat( | |
68 | + min: 22 / _composition.endFrame, | |
69 | + max: 37 / _composition.endFrame, | |
70 | + ); | |
71 | + } | |
72 | + | |
73 | + void _playFinalAnimation() { | |
74 | + _controller.stop(); | |
75 | + _controller | |
76 | + .animateTo(50 / _composition.endFrame) | |
77 | + .whenComplete(() => _resetAnimation()); | |
78 | + } | |
79 | + | |
80 | + void _resetAnimation() { | |
81 | + setState(() { | |
82 | + _isPlaying = false; | |
83 | + _controller.value = 0; | |
84 | + }); | |
85 | + } | |
86 | + | |
87 | + @override | |
88 | + void didUpdateWidget(RecorderWidget oldWidget) { | |
89 | + super.didUpdateWidget(oldWidget); | |
90 | + Log.d("$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying"); | |
91 | + if (widget.isPlaying && !_isPlaying) { | |
92 | + setState(() { | |
93 | + _isPlaying = true; | |
94 | + }); | |
95 | + _playInitialAnimation(); | |
96 | + } else if (!widget.isPlaying && _isPlaying) { | |
97 | + _playFinalAnimation(); | |
98 | + } | |
99 | + } | |
100 | + | |
101 | + @override | |
102 | + void dispose() { | |
103 | + _controller.dispose(); | |
104 | + super.dispose(); | |
105 | + } | |
106 | + | |
107 | + @override | |
108 | + Widget build(BuildContext context) { | |
109 | + return ThrottledGestureDetector( | |
110 | + onTap: widget.isClickable ? widget.onTap : null, | |
111 | + child: Opacity( | |
112 | + opacity: widget.isClickable ? 1.0 : 0.5, // 设置透明度 | |
113 | + child: Lottie( | |
114 | + composition: _composition, | |
115 | + controller: _controller, | |
116 | + renderCache: RenderCache.raster, | |
117 | + width: widget.width, | |
118 | + height: widget.height, | |
119 | + ), | |
120 | + ), | |
121 | + ); | |
122 | + } | |
123 | +} | ... | ... |
lib/common/widgets/speaker_widget.dart
... | ... | @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; |
2 | 2 | import 'dart:async'; |
3 | 3 | |
4 | 4 | import 'package:wow_english/common/extension/string_extension.dart'; |
5 | +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart'; | |
6 | + | |
7 | +import '../../utils/log_util.dart'; | |
5 | 8 | |
6 | 9 | /// 扬声器播放组件,帧动画 |
7 | 10 | class SpeakerWidget extends StatefulWidget { |
... | ... | @@ -34,6 +37,9 @@ class _SpeakerWidgetState extends State<SpeakerWidget> |
34 | 37 | 'ic_speaker_play0'.assetPng, |
35 | 38 | 'ic_speaker_play1'.assetPng, |
36 | 39 | ]; |
40 | + static const String TAG = "SpeakerWidget"; | |
41 | + | |
42 | + bool _isPlaying = false; | |
37 | 43 | |
38 | 44 | @override |
39 | 45 | void initState() { |
... | ... | @@ -51,12 +57,15 @@ class _SpeakerWidgetState extends State<SpeakerWidget> |
51 | 57 | @override |
52 | 58 | void didUpdateWidget(SpeakerWidget oldWidget) { |
53 | 59 | super.didUpdateWidget(oldWidget); |
54 | - if (widget.isPlaying != oldWidget.isPlaying) { | |
55 | - if (widget.isPlaying) { | |
56 | - _startAnimation(); | |
57 | - } else { | |
58 | - _stopAnimation(); | |
59 | - } | |
60 | + Log.d( | |
61 | + "$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying"); | |
62 | + if (widget.isPlaying && !_isPlaying) { | |
63 | + setState(() { | |
64 | + _isPlaying = true; | |
65 | + }); | |
66 | + _startAnimation(); | |
67 | + } else if (!widget.isPlaying && _isPlaying) { | |
68 | + _stopAnimation(); | |
60 | 69 | } |
61 | 70 | } |
62 | 71 | |
... | ... | @@ -71,6 +80,7 @@ class _SpeakerWidgetState extends State<SpeakerWidget> |
71 | 80 | void _stopAnimation() { |
72 | 81 | _timer?.cancel(); |
73 | 82 | setState(() { |
83 | + _isPlaying = false; | |
74 | 84 | _currentFrame = 0; |
75 | 85 | }); |
76 | 86 | } |
... | ... | @@ -84,13 +94,19 @@ class _SpeakerWidgetState extends State<SpeakerWidget> |
84 | 94 | |
85 | 95 | @override |
86 | 96 | Widget build(BuildContext context) { |
87 | - return GestureDetector( | |
97 | + return ThrottledGestureDetector( | |
88 | 98 | onTap: widget.isClickable ? widget.onTap : null, |
89 | - child: Image.asset( | |
90 | - _speakerImagePaths[_currentFrame], | |
91 | - width: widget.width, | |
92 | - height: widget.height, | |
93 | - fit: BoxFit.cover, | |
99 | + child: Opacity( | |
100 | + opacity: widget.isClickable ? 1.0 : 0.5, | |
101 | + child: Image.asset( | |
102 | + _speakerImagePaths[_currentFrame], | |
103 | + width: widget.width, | |
104 | + height: widget.height, | |
105 | + fit: BoxFit.cover, | |
106 | + color: widget.isClickable ? null : Colors.transparent, | |
107 | + // 灰色遮罩 | |
108 | + colorBlendMode: widget.isClickable ? null : BlendMode.saturation, | |
109 | + ), | |
94 | 110 | ), |
95 | 111 | ); |
96 | 112 | } | ... | ... |
lib/common/widgets/throttledGesture_gesture_detector.dart
... | ... | @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; |
4 | 4 | ///带节流功能的GestureDetector |
5 | 5 | class ThrottledGestureDetector extends StatefulWidget { |
6 | 6 | final Widget child; |
7 | - final VoidCallback onTap; | |
7 | + final VoidCallback? onTap; | |
8 | 8 | final int throttleTime; |
9 | 9 | |
10 | 10 | const ThrottledGestureDetector({ |
... | ... | @@ -24,7 +24,9 @@ class _ThrottledGestureDetectorState extends State<ThrottledGestureDetector> { |
24 | 24 | |
25 | 25 | void _handleTap() { |
26 | 26 | if (!_isThrottled) { |
27 | - widget.onTap(); | |
27 | + if (widget.onTap != null) { | |
28 | + widget.onTap!(); | |
29 | + } | |
28 | 30 | _isThrottled = true; |
29 | 31 | Timer(Duration(milliseconds: widget.throttleTime), () { |
30 | 32 | _isThrottled = false; | ... | ... |
lib/pages/practice/bloc/topic_picture_bloc.dart
... | ... | @@ -53,7 +53,7 @@ class TopicPictureBloc |
53 | 53 | CourseProcessEntity? get entity => _entity; |
54 | 54 | |
55 | 55 | ///正在评测 |
56 | - bool _isVoicing = false; | |
56 | + bool _isRecording = false; | |
57 | 57 | |
58 | 58 | ///正在播放音频 |
59 | 59 | VoicePlayState _voicePlayState = VoicePlayState.unKnow; |
... | ... | @@ -72,7 +72,7 @@ class TopicPictureBloc |
72 | 72 | |
73 | 73 | int get selectItem => _selectItem; |
74 | 74 | |
75 | - bool get isVoicing => _isVoicing; | |
75 | + bool get isRecording => _isRecording; | |
76 | 76 | |
77 | 77 | VoicePlayState get voicePlayState => _voicePlayState; |
78 | 78 | |
... | ... | @@ -277,7 +277,7 @@ class TopicPictureBloc |
277 | 277 | 'type': event.type, |
278 | 278 | 'userId': event.userId.toString() |
279 | 279 | }); |
280 | - _isVoicing = true; | |
280 | + _isRecording = true; | |
281 | 281 | emitter(XSVoiceTestState()); |
282 | 282 | } |
283 | 283 | } |
... | ... | @@ -300,7 +300,7 @@ class TopicPictureBloc |
300 | 300 | final result = args['result'] as Map; |
301 | 301 | final overall = result['overall'].toString(); |
302 | 302 | showToast('测评成功,分数是$overall', duration: const Duration(seconds: 5)); |
303 | - _isVoicing = false; | |
303 | + _isRecording = false; | |
304 | 304 | emitter(XSVoiceTestState()); |
305 | 305 | if (isLastPage()) { |
306 | 306 | showStepPage(); | ... | ... |
lib/pages/practice/topic_picture_page.dart
... | ... | @@ -11,9 +11,8 @@ import 'package:wow_english/pages/practice/widgets/shake_widget.dart'; |
11 | 11 | import 'package:wow_english/route/route.dart'; |
12 | 12 | import 'package:wow_english/utils/toast_util.dart'; |
13 | 13 | |
14 | +import '../../common/widgets/recorder_widget.dart'; | |
14 | 15 | import '../../common/widgets/speaker_widget.dart'; |
15 | -import '../../common/widgets/throttledGesture_gesture_detector.dart'; | |
16 | -import '../../utils/log_util.dart'; | |
17 | 16 | import 'bloc/topic_picture_bloc.dart'; |
18 | 17 | import 'widgets/practice_header_widget.dart'; |
19 | 18 | |
... | ... | @@ -167,10 +166,10 @@ class _TopicPicturePage extends StatelessWidget { |
167 | 166 | builder: (context, state) { |
168 | 167 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
169 | 168 | final isAnswerOption = bloc.selectItem == index; |
170 | - final answerCorrect = isAnswerOption && | |
171 | - bloc.checkAnswerRight(index) == true; | |
172 | - final answerIncorrect = isAnswerOption && | |
173 | - bloc.checkAnswerRight(index) == false; | |
169 | + final answerCorrect = | |
170 | + isAnswerOption && bloc.checkAnswerRight(index) == true; | |
171 | + final answerIncorrect = | |
172 | + isAnswerOption && bloc.checkAnswerRight(index) == false; | |
174 | 173 | return Container( |
175 | 174 | padding: EdgeInsets.symmetric(horizontal: 10.w), |
176 | 175 | child: GestureDetector( |
... | ... | @@ -239,10 +238,10 @@ class _TopicPicturePage extends StatelessWidget { |
239 | 238 | builder: (context, state) { |
240 | 239 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
241 | 240 | final isAnswerOption = bloc.selectItem == index; |
242 | - final answerCorrect = isAnswerOption && | |
243 | - bloc.checkAnswerRight(index) == true; | |
244 | - final answerIncorrect = isAnswerOption && | |
245 | - bloc.checkAnswerRight(index) == false; | |
241 | + final answerCorrect = | |
242 | + isAnswerOption && bloc.checkAnswerRight(index) == true; | |
243 | + final answerIncorrect = | |
244 | + isAnswerOption && bloc.checkAnswerRight(index) == false; | |
246 | 245 | return Container( |
247 | 246 | padding: EdgeInsets.symmetric(horizontal: 10.w), |
248 | 247 | child: GestureDetector( |
... | ... | @@ -348,10 +347,10 @@ class _TopicPicturePage extends StatelessWidget { |
348 | 347 | builder: (context, state) { |
349 | 348 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
350 | 349 | final isAnswerOption = bloc.selectItem == index; |
351 | - final answerCorrect = isAnswerOption && | |
352 | - bloc.checkAnswerRight(index) == true; | |
353 | - final answerIncorrect = isAnswerOption && | |
354 | - bloc.checkAnswerRight(index) == false; | |
350 | + final answerCorrect = | |
351 | + isAnswerOption && bloc.checkAnswerRight(index) == true; | |
352 | + final answerIncorrect = | |
353 | + isAnswerOption && bloc.checkAnswerRight(index) == false; | |
355 | 354 | return ShakeWidget( |
356 | 355 | shouldShake: answerIncorrect, |
357 | 356 | child: Container( |
... | ... | @@ -425,10 +424,10 @@ class _TopicPicturePage extends StatelessWidget { |
425 | 424 | builder: (context, state) { |
426 | 425 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
427 | 426 | final isAnswerOption = bloc.selectItem == index; |
428 | - final answerCorrect = isAnswerOption && | |
429 | - bloc.checkAnswerRight(index) == true; | |
430 | - final answerIncorrect = isAnswerOption && | |
431 | - bloc.checkAnswerRight(index) == false; | |
427 | + final answerCorrect = | |
428 | + isAnswerOption && bloc.checkAnswerRight(index) == true; | |
429 | + final answerIncorrect = | |
430 | + isAnswerOption && bloc.checkAnswerRight(index) == false; | |
432 | 431 | return GestureDetector( |
433 | 432 | onTap: () => bloc.add(SelectItemEvent(index)), |
434 | 433 | child: ShakeWidget( |
... | ... | @@ -505,40 +504,31 @@ class _TopicPicturePage extends StatelessWidget { |
505 | 504 | Column( |
506 | 505 | mainAxisAlignment: MainAxisAlignment.center, |
507 | 506 | children: [ |
508 | - GestureDetector( | |
509 | - onTap: () { | |
510 | - if (bloc.isVoicing) { | |
511 | - showToast('正在录音,不能终止'); | |
512 | - return; | |
513 | - } | |
514 | - bloc.add(VoicePlayEvent()); | |
515 | - }, | |
516 | - child: Row( | |
517 | - children: [ | |
518 | - SpeakerWidget( | |
519 | - isPlaying: bloc.voicePlayState == VoicePlayState.playing, // 控制动画播放 | |
520 | - isClickable: true, // 控制是否可点击 | |
521 | - width: 45.w, | |
522 | - height: 45.w, | |
523 | - onTap: () { | |
524 | - Log.d("Speaker tapped"); | |
525 | - }, | |
526 | - ), | |
527 | - 10.horizontalSpace, | |
528 | - Text(topics?.word ?? '') | |
529 | - ], | |
530 | - ), | |
507 | + Row( | |
508 | + children: [ | |
509 | + SpeakerWidget( | |
510 | + isPlaying: bloc.voicePlayState == VoicePlayState.playing, | |
511 | + // 控制动画播放 | |
512 | + isClickable: !bloc.isRecording, | |
513 | + // 控制是否可点击 | |
514 | + width: 45.w, | |
515 | + height: 45.w, | |
516 | + onTap: () { | |
517 | + bloc.add(VoicePlayEvent()); | |
518 | + }, | |
519 | + ), | |
520 | + 10.horizontalSpace, | |
521 | + Text(topics?.word ?? '') | |
522 | + ], | |
531 | 523 | ), |
532 | 524 | 70.verticalSpace, |
533 | - ThrottledGestureDetector( | |
534 | - throttleTime: 1000, | |
525 | + RecorderWidget( | |
526 | + isPlaying: bloc.isRecording, | |
527 | + isClickable: bloc.voicePlayState != VoicePlayState.playing, | |
528 | + width: 72.w, | |
529 | + height: 72.w, | |
535 | 530 | onTap: () { |
536 | - if (bloc.voicePlayState == VoicePlayState.playing) { | |
537 | - showToast('正在播放音频,不能终止'); | |
538 | - return; | |
539 | - } | |
540 | - | |
541 | - if (bloc.isVoicing) { | |
531 | + if (bloc.isRecording) { | |
542 | 532 | bloc.add(XSVoiceStopEvent()); |
543 | 533 | return; |
544 | 534 | } |
... | ... | @@ -551,14 +541,7 @@ class _TopicPicturePage extends StatelessWidget { |
551 | 541 | UserUtil.getUser()!.id.toString())); |
552 | 542 | } |
553 | 543 | }, |
554 | - child: Image.asset( | |
555 | - bloc.isVoicing | |
556 | - ? 'micro_phone'.assetGif | |
557 | - : 'micro_phone'.assetPng, | |
558 | - height: 46.h, | |
559 | - width: 46.w, | |
560 | - ), | |
561 | - ) | |
544 | + ), | |
562 | 545 | ], |
563 | 546 | ) |
564 | 547 | ], | ... | ... |