diff --git a/CHANGELOG.md b/CHANGELOG.md index c0193cad..d68296f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Feature [#468](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/pull/468) - Add macOS support - Feature [#91](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/91) - Add `WaveStyle.maxDuration` to bound recording waveform width proportionally +- Feature [#382](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/382) - Smooth recorder waveform scroll with frame-interpolated animation ## 2.0.2 diff --git a/lib/src/audio_waveforms.dart b/lib/src/audio_waveforms.dart index a49f8c80..006dc0d1 100644 --- a/lib/src/audio_waveforms.dart +++ b/lib/src/audio_waveforms.dart @@ -35,13 +35,33 @@ class AudioWaveforms extends StatefulWidget { State createState() => _AudioWaveformsState(); } -class _AudioWaveformsState extends State { +class _AudioWaveformsState extends State + with SingleTickerProviderStateMixin { bool _isScrolled = false; - /// Tracks the total horizontal offset applied when the waveform is shifted backward. - Offset _totalBackDistance = Offset.zero; + /// Duration of the frame-interpolated scroll animation applied on each + /// pushback. + static const Duration _scrollAnimDuration = Duration(milliseconds: 100); + Offset _dragOffset = Offset.zero; + late final AnimationController _scrollAnim; + + /// Current (frame-interpolated) horizontal offset the waveform is shifted + /// backward. A bare scalar — the waveform only ever scrolls on the x-axis. + double _renderedBackDx = 0.0; + + /// Logical (final) back-distance target; updated immediately on each pushback. + double _logicalBackDx = 0.0; + + /// Animated source position at the moment the current animation started. + double _animSourceDx = 0.0; + + /// True while a post-frame callback to (re)start [_scrollAnim] is pending. + /// Coalesces multiple pushbacks within one frame into a single animation + /// start and keeps the controller from being mutated during paint. + bool _scrollAnimScheduled = false; + double _initialOffsetPosition = 0.0; late double _initialPosition; Duration currentlyRecordedDuration = Duration.zero; @@ -64,6 +84,17 @@ class _AudioWaveformsState extends State { // For RTL, initial position starts at 0 (waves grow from right edge) // For LTR, initial position starts at negative half thickness _initialPosition = _isRtl ? 0.0 : -(_waveStyle.waveThickness / 2); + _scrollAnim = AnimationController( + vsync: this, + duration: _scrollAnimDuration, + )..addListener(() { + // Only update the interpolated offset here. The AnimatedBuilder around + // the CustomPaint repaints the waveform on each tick, so there is no + // need to setState and rebuild the whole subtree (and re-run the + // clipper) every frame. + _renderedBackDx = _animSourceDx + + (_logicalBackDx - _animSourceDx) * _scrollAnim.value; + }); _recorderController.addListener(_recorderControllerListener); streamSubscription = _recorderController.onCurrentDuration.listen((duration) { @@ -96,6 +127,7 @@ class _AudioWaveformsState extends State { @override void dispose() { + _scrollAnim.dispose(); _recorderController.removeListener(_recorderControllerListener); streamSubscription.cancel(); super.dispose(); @@ -119,45 +151,50 @@ class _AudioWaveformsState extends State { waveWidth: _waveWidth, ), child: RepaintBoundary( - child: CustomPaint( - size: _size, - painter: RecorderWavePainter( - labels: _labels, - waveThickness: _waveStyle.waveThickness, - middleLineThickness: _waveStyle.middleLineThickness, - middleLineColor: _waveStyle.middleLineColor, - waveData: _recorderController.waveData, - callPushback: _recorderController.shouldRefresh, - bottomPadding: _waveStyle.bottomPadding ?? _size.height / 2, - spacing: _waveStyle.spacing, - waveCap: _waveStyle.waveCap, - showBottom: _waveStyle.showBottom, - showTop: _waveStyle.showTop, - waveColor: _waveStyle.waveColor, - showMiddleLine: _waveStyle.showMiddleLine, - totalCurrentBackDistance: _totalBackDistance, - dragOffset: _dragOffset, - pushBack: _pushBackWave, - initialPosition: _initialPosition, - extendWaveform: _waveStyle.extendWaveform, - showHourInDuration: _waveStyle.showHourInDuration, - showDurationLabel: _waveStyle.showDurationLabel, - durationLinesColor: _waveStyle.durationLinesColor, - durationStyle: _waveStyle.durationStyle, - durationTextPadding: _waveStyle.durationTextPadding, - durationLinesHeight: _waveStyle.durationLinesHeight, - labelSpacing: _waveStyle.labelSpacing, - gradient: _waveStyle.gradient, - shouldClearLabels: _recorderController.shouldClearLabels, - revertClearLabelCall: _recorderController.revertClearLabelCall, - setCurrentPositionDuration: - _recorderController.setScrolledPositionDuration, - shouldCalculateScrolledPosition: - widget.shouldCalculateScrolledPosition, - scaleFactor: _waveStyle.scaleFactor, - currentlyRecordedDuration: currentlyRecordedDuration, - isRtl: _isRtl, - maxDuration: _waveStyle.maxDuration, + child: AnimatedBuilder( + animation: _scrollAnim, + builder: (context, _) => CustomPaint( + size: _size, + painter: RecorderWavePainter( + labels: _labels, + waveThickness: _waveStyle.waveThickness, + middleLineThickness: _waveStyle.middleLineThickness, + middleLineColor: _waveStyle.middleLineColor, + waveData: _recorderController.waveData, + callPushback: _recorderController.shouldRefresh, + bottomPadding: _waveStyle.bottomPadding ?? _size.height / 2, + spacing: _waveStyle.spacing, + waveCap: _waveStyle.waveCap, + showBottom: _waveStyle.showBottom, + showTop: _waveStyle.showTop, + waveColor: _waveStyle.waveColor, + showMiddleLine: _waveStyle.showMiddleLine, + renderedBackDistance: _renderedBackDx, + logicalBackDistance: _logicalBackDx, + dragOffset: _dragOffset, + pushBack: _pushBackWave, + initialPosition: _initialPosition, + extendWaveform: _waveStyle.extendWaveform, + showHourInDuration: _waveStyle.showHourInDuration, + showDurationLabel: _waveStyle.showDurationLabel, + durationLinesColor: _waveStyle.durationLinesColor, + durationStyle: _waveStyle.durationStyle, + durationTextPadding: _waveStyle.durationTextPadding, + durationLinesHeight: _waveStyle.durationLinesHeight, + labelSpacing: _waveStyle.labelSpacing, + gradient: _waveStyle.gradient, + shouldClearLabels: _recorderController.shouldClearLabels, + revertClearLabelCall: + _recorderController.revertClearLabelCall, + setCurrentPositionDuration: + _recorderController.setScrolledPositionDuration, + shouldCalculateScrolledPosition: + widget.shouldCalculateScrolledPosition, + scaleFactor: _waveStyle.scaleFactor, + currentlyRecordedDuration: currentlyRecordedDuration, + isRtl: _isRtl, + maxDuration: _waveStyle.maxDuration, + ), ), ), ), @@ -212,10 +249,10 @@ class _AudioWaveformsState extends State { ///This will also handle refreshing the wave after scrolled void _pushBackWave() { if (_isRtl) { - if (!_isScrolled) { - _totalBackDistance = - _totalBackDistance + Offset(_waveStyle.spacing, 0.0); - } + // Note: the frame-interpolated scroll smoothing (_scrollAnim) is applied + // to LTR only. RTL positions bars from the right edge by index + // (see RecorderWavePainter._drawRtlWave) and never reads the back-distance + // offsets, so RTL recording advances one bar per sample on its own. // For RTL: handle refresh after scrolling if (_recorderController.shouldRefresh && _isScrolled) { @@ -234,19 +271,36 @@ class _AudioWaveformsState extends State { _initialPosition = _waveStyle.spacing * _recorderController.waveData.length - _size.width / 2; - _totalBackDistance = - _totalBackDistance + Offset(_waveStyle.spacing, 0.0); + _logicalBackDx += _waveStyle.spacing; + _renderedBackDx = _logicalBackDx; + _animSourceDx = _logicalBackDx; + _scrollAnim.stop(); _isScrolled = false; } else { _initialPosition = 0.0; - _totalBackDistance = - _totalBackDistance + Offset(_waveStyle.spacing, 0.0); + _logicalBackDx += _waveStyle.spacing; + // Mutating the AnimationController during paint would call setState in + // the paint phase, so defer the (re)start to after the frame; a guard coalesces multiple pushbacks per frame into one animation. + if (!_scrollAnimScheduled) { + _scrollAnimScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollAnimScheduled = false; + if (!mounted) return; + _animSourceDx = _renderedBackDx; + _scrollAnim + ..reset() + ..forward(); + }); + } } } if (_recorderController.shouldClearLabels) { _initialOffsetPosition = 0.0; - _totalBackDistance = Offset.zero; + _renderedBackDx = 0.0; _dragOffset = Offset.zero; + _logicalBackDx = 0.0; + _animSourceDx = 0.0; + _scrollAnim.stop(); } } @@ -267,19 +321,19 @@ class _AudioWaveformsState extends State { final delta = details.delta; final deltaDx = details.delta.dx; final dragOffset = _dragOffset.dx; - final totalBackDistanceDx = -_totalBackDistance.dx; + final renderedBackDistanceDx = -_renderedBackDx; final halfWidth = _size.width / 2; final waveformWidth = _waveStyle.spacing * _recorderController.waveData.length; ///left to right - if (totalBackDistanceDx + dragOffset + deltaDx < halfWidth && + if (renderedBackDistanceDx + dragOffset + deltaDx < halfWidth && direction > 0) { setState(() => _dragOffset += delta); } ///right to left - else if (totalBackDistanceDx + dragOffset + waveformWidth + deltaDx > + else if (renderedBackDistanceDx + dragOffset + waveformWidth + deltaDx > halfWidth && direction < 0) { setState(() => _dragOffset += delta); diff --git a/lib/src/painters/recorder_wave_painter.dart b/lib/src/painters/recorder_wave_painter.dart index afbb0b77..f7b25eea 100644 --- a/lib/src/painters/recorder_wave_painter.dart +++ b/lib/src/painters/recorder_wave_painter.dart @@ -8,11 +8,11 @@ import '/src/base/label.dart'; /// ///this gives location of first wave from right to left when scrolling /// -///-totalBackDistance.dx + dragOffset.dx + (spacing * i) +///-renderedBackDistance + dragOffset.dx + (spacing * i) /// ///this gives location of first wave from left to right when scrolling /// -///-totalBackDistance.dx + dragOffset.dx +///-renderedBackDistance + dragOffset.dx class RecorderWavePainter extends CustomPainter { RecorderWavePainter({ required this.waveData, @@ -26,7 +26,8 @@ class RecorderWavePainter extends CustomPainter { required this.waveCap, required this.middleLineColor, required this.middleLineThickness, - required this.totalCurrentBackDistance, + required this.renderedBackDistance, + required this.logicalBackDistance, required this.dragOffset, required this.waveThickness, required this.pushBack, @@ -72,8 +73,12 @@ class RecorderWavePainter extends CustomPainter { final Color middleLineColor; final double middleLineThickness; - /// This gives total current distance the waves have been pushed back - final Offset totalCurrentBackDistance; + /// Current (frame-interpolated) distance the waves have been pushed back. + final double renderedBackDistance; + + /// Logical (non-animated) back distance — used for overflow check to + /// prevent double-triggering pushBack during an in-flight animation. + final double logicalBackDistance; final Offset dragOffset; final double waveThickness; final VoidCallback pushBack; @@ -135,8 +140,7 @@ class RecorderWavePainter extends CustomPainter { // never scrolls, so pushBack is skipped. if (!_isBounded && ((spacing * i) + dragOffset.dx + spacing > - size.width / (extendWaveform ? 1 : 2) + - totalCurrentBackDistance.dx) && + size.width / (extendWaveform ? 1 : 2) + logicalBackDistance) && callPushback) { pushBack(); } @@ -180,7 +184,7 @@ class RecorderWavePainter extends CustomPainter { final labelX = size.width - distanceFromRight + dragOffset.dx; offset = Offset(labelX, label.offset.dy); } else { - offset = label.offset - totalCurrentBackDistance + dragOffset; + offset = label.offset - Offset(renderedBackDistance, 0) + dragOffset; } final halfWidth = size.width * 0.5; @@ -226,7 +230,7 @@ class RecorderWavePainter extends CustomPainter { // the width equal to currentlyRecordedDuration / maxDuration. No scroll. dx = _boundedDx(size, i); } else { - dx = -totalCurrentBackDistance.dx + + dx = -renderedBackDistance + dragOffset.dx + (spacing * i) - initialPosition; @@ -306,9 +310,11 @@ class RecorderWavePainter extends CustomPainter { } void _setScrolledDuration(Size size) { + // Use the logical (committed) back-distance, not the in-flight animated + // value, so the reported position reflects the true scroll target and does + // not lag/oscillate during the frame-interpolated scroll animation. setCurrentPositionDuration( - (((-totalCurrentBackDistance.dx + dragOffset.dx - (size.width / 2)) / - spacing) * + (((-logicalBackDistance + dragOffset.dx - (size.width / 2)) / spacing) * 1000) .abs() .toInt(),