diff --git a/CHANGELOG.md b/CHANGELOG.md index d0bd041..80052d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.0.2 + +* Bouncing animation added + ## 0.0.1 * Initial release. diff --git a/example/lib/main.dart b/example/lib/main.dart index 6abb444..4503205 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,4 @@ -import 'package:auto_scroll_text/auto_scroll_text.dart'; +import 'package:auto_scroll_text/auto_scroll_text_impl.dart'; import 'package:flutter/material.dart'; // Created by Bomsamdi on 2022 @@ -49,6 +49,12 @@ class _MyHomePageState extends State { ElevatedButton( onPressed: _openVertical, child: const Text("Open VERTICAL example")), + ElevatedButton( + onPressed: _openBouncingHorizontal, + child: const Text("Open BOUNCING HORIZONTAL example")), + ElevatedButton( + onPressed: _openBouncingVertical, + child: const Text("Open BOUNCING VERTICAL example")), ], ), ), @@ -66,6 +72,18 @@ class _MyHomePageState extends State { builder: (context) => const VerticalExample(), )); } + + void _openBouncingHorizontal() { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const BouncingHorizontalExample(), + )); + } + + void _openBouncingVertical() { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => const BouncingVerticalExample(), + )); + } } class HorizontalExample extends StatefulWidget { @@ -88,9 +106,8 @@ class _HorizontalExampleState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: const [ AutoScrollText( - text: - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", - textStyle: TextStyle(fontSize: 24), + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", + style: TextStyle(fontSize: 24), ), ], ), @@ -119,10 +136,74 @@ class _VerticalExampleState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: const [ AutoScrollText( - text: - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", - textStyle: TextStyle(fontSize: 24), + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", + style: TextStyle(fontSize: 24), + scrollDirection: Axis.vertical, + ), + ], + ), + ), + ); + } +} + +class BouncingHorizontalExample extends StatefulWidget { + const BouncingHorizontalExample({Key? key}) : super(key: key); + + @override + State createState() => + _BouncingHorizontalExampleState(); +} + +class _BouncingHorizontalExampleState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Bouncing Horizontal Example"), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + AutoScrollText( + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", + style: TextStyle(fontSize: 24), + mode: AutoScrollTextMode.bouncing, + ), + ], + ), + ), + ); + } +} + +class BouncingVerticalExample extends StatefulWidget { + const BouncingVerticalExample({Key? key}) : super(key: key); + + @override + State createState() => + _BouncingVerticalExampleState(); +} + +class _BouncingVerticalExampleState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Bouncing Vertical Example"), + ), + body: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: const [ + AutoScrollText( + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", + style: TextStyle(fontSize: 24), scrollDirection: Axis.vertical, + mode: AutoScrollTextMode.bouncing, ), ], ), diff --git a/example/pubspec.lock b/example/pubspec.lock index fe24d90..6616cea 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -14,7 +14,7 @@ packages: path: ".." relative: true source: path - version: "0.0.1" + version: "0.0.2" boolean_selector: dependency: transitive description: diff --git a/lib/after_layout_mixin.dart b/lib/after_layout_mixin.dart deleted file mode 100644 index c95552b..0000000 --- a/lib/after_layout_mixin.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; -// Created by Bomsamdi on 2022 -// Copyright © 2022 Bomsamdi. All rights reserved. -import 'package:flutter/widgets.dart'; - -mixin AfterLayoutMixin on State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.endOfFrame.then( - (_) { - if (mounted) afterFirstLayout(context); - }, - ); - } - - FutureOr afterFirstLayout(BuildContext context); -} diff --git a/lib/auto_scroll_text.dart b/lib/auto_scroll_text.dart index 076f68b..00937d7 100644 --- a/lib/auto_scroll_text.dart +++ b/lib/auto_scroll_text.dart @@ -1,148 +1,429 @@ -library auto_scroll_text; - // Created by Bomsamdi on 2022 // Copyright © 2022 Bomsamdi. All rights reserved. import 'dart:async'; + +import 'package:auto_scroll_text/auto_scroll_text_mode.dart'; import 'package:flutter/material.dart'; -import 'after_layout_mixin.dart'; -/// [AutoScrollText] is a solution when you need -/// text widget for long texts without overlaping or overflow.elipsis -/// [AutoScrollText] supports both directions [Axis.horizontal] and [Axis.vertical] +/// AutoScrollText widget automatically scrolls provided [text] +/// +/// ### Example: +/// +/// ```dart +/// AutoScrollText( +/// "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", +/// mode: AutoScrollTextMode.bouncing, +/// velocity: Velocity(pixelsPerSecond: Offset(150, 0)), +/// delayBefore: Duration(milliseconds: 500), +/// numberOfReps: 5, +/// pauseBetween: Duration(milliseconds: 50), +/// style: TextStyle(color: Colors.green), +/// textAlign: TextAlign.right, +/// selectable: true, +/// scrollDirection: Axis.horizontal, +/// curve: Curves.linear, +/// ) +/// ``` class AutoScrollText extends StatefulWidget { - /// [Text.text] of [AutoScrollText] + const AutoScrollText( + this.text, { + Key? key, + this.style, + this.textAlign, + this.textDirection = TextDirection.ltr, + this.numberOfReps, + this.delayBefore, + this.pauseBetween, + this.mode = AutoScrollTextMode.endless, + this.velocity = const Velocity(pixelsPerSecond: Offset(80, 0)), + this.selectable = false, + this.intervalSpaces, + this.scrollDirection = Axis.horizontal, + this.curve = Curves.linear, + }) : super(key: key); + + /// The text string, that would be scrolled. + /// In case text does fit into allocated space, it wouldn't be scrolled + /// and would be shown as is. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText('A sample text for AutoScrollText widget.') + /// ``` final String text; - /// [TextStyle] of [Text] - final TextStyle? textStyle; + /// Provides [TextAlign] alignment if text string is not long enough to be scrolled. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Short text', + /// textAlign: TextAlign.right, + /// ) + /// ``` + final TextAlign? textAlign; - /// [SingleChildScrollView.scrollDirection] default value [Axis.horizontal] - final Axis scrollDirection; + /// Provides [TextDirection] - a direction in which text flows. + /// Default is [TextDirection.ltr]. + /// Default scrolling direction would be opposite to [textDirection], + /// e.g. for [TextDirection.rtl] scrolling would be from left to right + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'This is a RTL text. This is a RTL text. This is a RTL text. This is a RTL text. ', + /// textDirection: TextDirection.rtl, + /// ) + /// ``` + final TextDirection textDirection; - /// [Curve] of scroll animation - final Curve curve; + /// Allows to apply custom [TextStyle] to [text]. + /// + /// `null` by default. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Text with TextStyle', + /// style: TextStyle( + /// color: Colors.white, + /// ), + /// ) + /// ``` + final TextStyle? style; - /// Distance per tick - final double moveDistance; + /// Limits number of scroll animation rounds. + /// + /// Default is [infinity]. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Limit scroll rounds to 10', + /// numberOfReps: 10, + /// ) + /// ``` + final int? numberOfReps; - /// Reset timer - final int timerRest; + /// Delay before first animation round. + /// + /// Default is [Duration.zero]. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Start animation after 1 sec delay', + /// delayBefore: Duration(seconds: 1), + /// ) + /// ``` + final Duration? delayBefore; - const AutoScrollText({ - super.key, - required this.text, - this.textStyle, - this.scrollDirection = Axis.horizontal, - this.curve = Curves.linear, - this.moveDistance = 3.0, - this.timerRest = 100, - }); + /// Determines pause interval between animation rounds. + /// + /// Only allowed if [mode] is set to [AutoScrollTextMode.bouncing]. + /// + /// Default is [Duration.zero]. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Text with pause between animations', + /// mode: AutoScrollTextMode.bouncing, + /// pauseBetween: Duration(milliseconds: 300), + /// ) + /// ``` + final Duration? pauseBetween; - @override - State createState() { - return AutoScrollTextState(); - } -} - -class AutoScrollTextState extends State - with SingleTickerProviderStateMixin, AfterLayoutMixin { - /// Final text for scrolling - String textToScroll = ""; + /// Sets one of two different types of scrolling behavior. + /// [AutoScrollTextMode.endless] - default, scrolls text in one direction endlessly. + /// [AutoScrollTextMode.bouncing] - when [text] string is scrolled to its end, + /// starts animation to opposite direction. + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Animate text string back and forth', + /// mode: AutoScrollTextMode.bouncing, + /// ) + /// ``` + final AutoScrollTextMode mode; - /// SingleChildScrollView controller - final ScrollController _scrollController = ScrollController(); + /// Allows to customize animation speed. + /// + /// Default is `Velocity(pixelsPerSecond: Offset(80, 0))` + /// + /// ### Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Text with animation of 100px per second', + /// velocity: Velocity(pixelsPerSecond: Offset(100, 0)), + /// ) + final Velocity velocity; - /// Actual position of scroll - double position = 0.0; + /// Allows users to select provided [text], copy it to clipboard etc. + /// + /// Default is `false`. + /// + /// Example: + /// + /// ```dart + /// AutoScrollText( + /// 'This text is has possibility to select and copy it to clipboard', + /// selectable: true, + /// ) + /// ``` + final bool selectable; - /// Repeatable timer - Timer? timer; + /// Adds blank spaces between two nearby text sentences + /// in case of [AutoScrollTextMode.endless] + /// + /// Default is `1`. + /// + /// Example: + /// + /// ```dart + /// AutoScrollText( + /// 'This is the sample text for AutoScrollText widget. ', + /// blankSpaces: 10, + /// ) + /// ``` + final int? intervalSpaces; - /// if text is to long for axis, define auto scroll action - bool _isScrollable = false; + /// Allows users to define scrollDirection of [AutoScrollText] + /// + /// Default is [Axis.horizontal]. + /// + /// Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Text with vertical scroll direction', + /// scrollDirection: Axis.vertical, + /// ) + /// ``` + final Axis scrollDirection; - /// SingleChildScrollView key - final GlobalKey _scrollKey = GlobalKey(); + /// [Curve] of scroll animation + /// Allows users to define [Curve] of animation for [AutoScrollText] + /// + /// Default is [Curves.linear]. + /// + /// Example: + /// + /// ```dart + /// AutoScrollText( + /// 'Text with linear animation, + /// curve: Curves.linear, + /// ) + /// ``` + final Curve curve; - /// Default [TextStyle] if [widget.textStyle] is null - TextStyle get defaultTextStyle => const TextStyle(); + @override + State createState() => _AutoScrollTextState(); +} +class _AutoScrollTextState extends State { + final _scrollController = ScrollController(); + String _text = ""; + String? _endlessText; + double? _originalTextWidth; + Timer? _timer; + bool _running = false; + int _counter = 0; @override void initState() { - textToScroll = widget.text; super.initState(); - } - - /// Timer for animation - void _startTimer() { - if (_scrollKey.currentContext != null) { - timer = Timer.periodic(Duration(milliseconds: widget.timerRest), (timer) { - double maxScrollExtent = _scrollController.position.maxScrollExtent; - double pixels = _scrollController.position.pixels; - if (pixels + widget.moveDistance >= maxScrollExtent) { - position = 0; - _scrollController.jumpTo(position); - } - position += widget.moveDistance; - _scrollController.animateTo(position, - duration: Duration(milliseconds: widget.timerRest), - curve: widget.curve); - }); + if (widget.scrollDirection == Axis.vertical) { + String newString = widget.text.split("").join("\n"); + _text = newString; + } else { + _text = widget.text; } + final WidgetsBinding binding = WidgetsBinding.instance; + binding.addPostFrameCallback(_initScroll); } - /// Check if autoscroll animation is needed - void _checkIsAutoScrollNeeded() { - setState(() { - _isScrollable = _scrollController.position.maxScrollExtent > 0; - }); - } - - /// Text builder - Widget _text() { - if (widget.scrollDirection == Axis.vertical) { - String newString = textToScroll.split("").join("\n"); - return Text( - newString, - style: widget.textStyle ?? defaultTextStyle, - textAlign: TextAlign.center, - ); - } - return Text( - textToScroll, - style: widget.textStyle ?? defaultTextStyle, - textAlign: TextAlign.justify, - ); + @override + void didUpdateWidget(covariant AutoScrollText oldWidget) { + _onUpdate(oldWidget); + super.didUpdateWidget(oldWidget); } @override void dispose() { + _timer?.cancel(); super.dispose(); - // dispose timer when needed - timer?.cancel(); } @override Widget build(BuildContext context) { - return SingleChildScrollView( - key: _scrollKey, - scrollDirection: widget.scrollDirection, - controller: _scrollController, - physics: _isScrollable - ? const AlwaysScrollableScrollPhysics() - : const NeverScrollableScrollPhysics(), - child: _text(), + assert( + widget.pauseBetween == null || + widget.mode == AutoScrollTextMode.bouncing, + 'pauseBetween is only available in AutoScrollTextMode.bouncing mode'); + assert( + widget.intervalSpaces == null || + widget.mode == AutoScrollTextMode.endless, + 'intervalSpaces is only available in AutoScrollTextMode.endless mode'); + return Directionality( + textDirection: widget.textDirection, + child: Scrollbar( + controller: _scrollController, + thickness: 0, + child: SingleChildScrollView( + controller: _scrollController, + scrollDirection: widget.scrollDirection, + child: widget.selectable + ? SelectableText( + _endlessText ?? _text, + style: widget.style, + textAlign: widget.textAlign, + ) + : Text( + _endlessText ?? _text, + style: widget.style, + textAlign: widget.textAlign, + ), + ), + ), ); } - @override - FutureOr afterFirstLayout(BuildContext context) { - _checkIsAutoScrollNeeded(); - if (_isScrollable) { + Future _initScroll(_) async { + await _delayBeforeStartAnimation(); + _timer = Timer.periodic(const Duration(milliseconds: 50), (timer) { + if (!_available) { + timer.cancel(); + return; + } + final int? maxReps = widget.numberOfReps; + if (maxReps != null && _counter >= maxReps) { + timer.cancel(); + return; + } + if (!_running) _runAnimation(); + }); + } + + Future _runAnimation() async { + _running = true; + final int? maxReps = widget.numberOfReps; + if (maxReps == null || _counter < maxReps) { + _counter++; + switch (widget.mode) { + case AutoScrollTextMode.bouncing: + { + await _animateBouncing(); + break; + } + default: + { + await _animateEndless(); + } + } + } + _running = false; + } + + Future _animateEndless() async { + if (!_available) return; + final ScrollPosition position = _scrollController.position; + final bool needsScrolling = position.maxScrollExtent > 0; + if (!needsScrolling) { + if (_endlessText != null) setState(() => _endlessText = null); + return; + } + if (_endlessText == null || _originalTextWidth == null) { setState(() { - textToScroll = " $textToScroll "; + _originalTextWidth = + position.maxScrollExtent + position.viewportDimension; + _endlessText = _text + _getSpaces(widget.intervalSpaces ?? 1) + _text; }); - _startTimer(); + return; + } + final double endlessTextWidth = + position.maxScrollExtent + position.viewportDimension; + final double singleRoundExtent = endlessTextWidth - _originalTextWidth!; + final Duration duration = _getDuration(singleRoundExtent); + if (duration == Duration.zero) return; + if (!_available) return; + await _scrollController.animateTo( + singleRoundExtent, + duration: duration, + curve: widget.curve, + ); + if (!_available) return; + _scrollController.jumpTo(position.minScrollExtent); + } + + Future _animateBouncing() async { + final double maxExtent = _scrollController.position.maxScrollExtent; + final double minExtent = _scrollController.position.minScrollExtent; + final double extent = maxExtent - minExtent; + final Duration duration = _getDuration(extent); + if (duration == Duration.zero) return; + if (!_available) return; + await _scrollController.animateTo( + maxExtent, + duration: duration, + curve: widget.curve, + ); + if (!_available) return; + await _scrollController.animateTo( + minExtent, + duration: duration, + curve: widget.curve, + ); + if (!_available) return; + if (widget.pauseBetween != null) { + await Future.delayed(widget.pauseBetween!); } } + + Future _delayBeforeStartAnimation() async { + final Duration? delayBefore = widget.delayBefore; + if (delayBefore == null) return; + await Future.delayed(delayBefore); + } + + Duration _getDuration(double extent) { + final int milliseconds = + (extent * 1000 / widget.velocity.pixelsPerSecond.dx).round(); + return Duration(milliseconds: milliseconds); + } + + void _onUpdate(AutoScrollText oldWidget) { + if (widget.text != oldWidget.text && _endlessText != null) { + setState(() { + _endlessText = null; + _originalTextWidth = null; + if (widget.scrollDirection == Axis.vertical) { + String newString = widget.text.split("").join("\n"); + _text = newString; + } else { + _text = widget.text; + } + }); + _scrollController.jumpTo(_scrollController.position.minScrollExtent); + } + } + + String _getSpaces(int number) { + String spaces = ''; + for (int i = 0; i < number; i++) { + spaces += '\u{00A0}'; + } + return spaces; + } + + bool get _available => mounted && _scrollController.hasClients; } diff --git a/lib/auto_scroll_text_impl.dart b/lib/auto_scroll_text_impl.dart new file mode 100644 index 0000000..9d67f39 --- /dev/null +++ b/lib/auto_scroll_text_impl.dart @@ -0,0 +1,4 @@ +library auto_scroll_text; + +export 'package:auto_scroll_text/auto_scroll_text.dart'; +export 'package:auto_scroll_text/auto_scroll_text_mode.dart'; diff --git a/lib/auto_scroll_text_mode.dart b/lib/auto_scroll_text_mode.dart new file mode 100644 index 0000000..034dce9 --- /dev/null +++ b/lib/auto_scroll_text_mode.dart @@ -0,0 +1,11 @@ +// Created by Bomsamdi on 2022 +// Copyright © 2022 Bomsamdi. All rights reserved. + +/// Animation types for [AutoScrollText] widget. +/// [endless] - scrolls text in one direction endlessly. +/// [bouncing] - when text is scrolled to its end, +/// starts animation to opposite direction. +enum AutoScrollTextMode { + bouncing, + endless, +} diff --git a/pubspec.yaml b/pubspec.yaml index 0feb970..ba5aadb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: auto_scroll_text description: AutoScrollText is package for users which need a single line text widget without overlaping or TextOverflow.elipsis for long texts. -version: 0.0.1 +version: 0.0.2 homepage: https://github.com/Bomsamdi/auto_scroll_text environment: