From efed5955f7a091f86a233791f03b5af259c03126 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 23 Jul 2022 21:17:40 +0200 Subject: [PATCH] WIP: controller --- lib/src/base_spin_box.dart | 134 +++++++++++++++----------------- lib/src/cupertino/spin_box.dart | 28 +++---- lib/src/material/spin_box.dart | 44 +++++------ lib/src/spin_controller.dart | 124 +++++++++++++++++++++++++++++ lib/src/spin_formatter.dart | 70 ++++------------- test/test_spinbox.dart | 11 +-- test/test_utils.dart | 2 +- 7 files changed, 241 insertions(+), 172 deletions(-) create mode 100644 lib/src/spin_controller.dart diff --git a/lib/src/base_spin_box.dart b/lib/src/base_spin_box.dart index 361d8db..8b51b6f 100644 --- a/lib/src/base_spin_box.dart +++ b/lib/src/base_spin_box.dart @@ -23,6 +23,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'spin_controller.dart'; import 'spin_formatter.dart'; // ignore_for_file: public_member_api_docs @@ -38,30 +39,20 @@ abstract class BaseSpinBox extends StatefulWidget { int get decimals; int get digits; ValueChanged? get onChanged; - bool Function(double value)? get canChange; - VoidCallback? get beforeChange; - VoidCallback? get afterChange; bool get readOnly; FocusNode? get focusNode; + SpinController? get controller; } mixin SpinBoxMixin on State { - late double _value; - late double _cachedValue; + late final SpinController _controller; + late final TextEditingController _editor; late final FocusNode _focusNode; - late final TextEditingController _controller; - double get value => _value; - bool get hasFocus => _focusNode.hasFocus; + SpinController get controller => _controller; + TextEditingController get editor => _editor; FocusNode get focusNode => _focusNode; - TextEditingController get controller => _controller; - SpinFormatter get formatter => SpinFormatter( - min: widget.min, max: widget.max, decimals: widget.decimals); - - static double _parseValue(String text) => double.tryParse(text) ?? 0; - String _formatText(double value) { - return value.toStringAsFixed(widget.decimals).padLeft(widget.digits, '0'); - } + SpinFormatter get formatter => SpinFormatter(_controller); Map get bindings { return { @@ -76,67 +67,67 @@ mixin SpinBoxMixin on State { }; } + String _formatValue(double value) { + return formatter.formatValue(value, + decimals: widget.decimals, digits: widget.digits); + } + @override void initState() { super.initState(); - _value = widget.value; - _cachedValue = widget.value; - _controller = TextEditingController(text: _formatText(_value)); - _controller.addListener(_updateValue); + _controller = widget.controller ?? + SpinController( + min: widget.min, + max: widget.max, + value: widget.value, + decimals: widget.decimals, + ); + _controller.addListener(_handleValueChange); + _editor = TextEditingController(text: _formatValue(widget.value)); + _editor.addListener(_handleTextChange); _focusNode = widget.focusNode ?? FocusNode(); - _focusNode.addListener(_handleFocusChanged); + _focusNode.addListener(_handleFocusChange); } @override void dispose() { - _focusNode.removeListener(_handleFocusChanged); + _focusNode.removeListener(_handleFocusChange); if (widget.focusNode == null) { _focusNode.dispose(); } - _controller.dispose(); + _controller.removeListener(_handleValueChange); + if (widget.controller == null) { + _controller.dispose(); + } + _editor.dispose(); super.dispose(); } - void _stepUp() => setValue(value + widget.step); - void _stepDown() => setValue(value - widget.step); - - void _pageStepUp() => setValue(value + widget.pageStep!); - void _pageStepDown() => setValue(value - widget.pageStep!); + void _stepUp() => controller.value += widget.step; + void _stepDown() => controller.value -= widget.step; - void _updateValue() { - final v = _parseValue(_controller.text); - if (v == _value) return; + void _pageStepUp() => controller.value += widget.pageStep!; + void _pageStepDown() => controller.value -= widget.pageStep!; - if (widget.canChange?.call(v) == false) { - controller.text = _formatText(_cachedValue); - setState(() { - _value = _cachedValue; - }); - return; - } - - setState(() => _value = v); - widget.onChanged?.call(v); + void _handleValueChange() { + widget.onChanged?.call(_controller.value); + setState(() => _updateText(_controller.value)); } - void setValue(double v) { - final newValue = v.clamp(widget.min, widget.max); - if (newValue == value) return; - - if (widget.canChange?.call(newValue) == false) return; - - widget.beforeChange?.call(); - setState(() => _updateController(value, newValue)); - widget.afterChange?.call(); + void _handleTextChange() { + final value = _controller.parse(_editor.text); + if (value != null && value >= controller.min && value <= controller.max) { + _controller.value = value; + } } - void _updateController(double oldValue, double newValue) { - final text = _formatText(newValue); - final selection = _controller.selection; - final oldOffset = value.isNegative ? 1 : 0; - final newOffset = _parseValue(text).isNegative ? 1 : 0; + void _updateText(double value) { + final text = _formatValue(value); + final selection = _editor.selection; + final oldOffset = _controller.value.isNegative ? 1 : 0; + final newOffset = _controller.parse(text)?.isNegative == true ? 1 : 0; - _controller.value = _controller.value.copyWith( + _editor.value = _editor.value.copyWith( text: text, selection: selection.copyWith( baseOffset: selection.baseOffset - oldOffset + newOffset, @@ -146,37 +137,36 @@ mixin SpinBoxMixin on State { } @protected - void fixupValue(String value) { - final v = _parseValue(value); - if (value.isEmpty || (v < widget.min || v > widget.max)) { - // will trigger notify to _updateValue() - _controller.text = _formatText(_cachedValue); - } else { - _cachedValue = _value; + void fixupValue(String text) { + final value = _controller.parse(text); + if (value == null) { + _editor.text = _formatValue(_controller.value); + } else if (value < _controller.min || value > _controller.max) { + _controller.value = value.clamp(_controller.min, _controller.max); } } - void _handleFocusChanged() { - if (hasFocus) { + void _handleFocusChange() { + if (focusNode.hasFocus) { setState(_selectAll); } else { - fixupValue(_controller.text); + fixupValue(_editor.text); } } void _selectAll() { - _controller.selection = _controller.selection - .copyWith(baseOffset: 0, extentOffset: _controller.text.length); + _editor.selection = _editor.selection + .copyWith(baseOffset: 0, extentOffset: _editor.text.length); } @override void didUpdateWidget(T oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { - _controller.removeListener(_updateValue); - _value = _cachedValue = widget.value; - _updateController(oldWidget.value, widget.value); - _controller.addListener(_updateValue); + _editor.removeListener(_handleTextChange); + _controller.value = widget.value; + _updateText(widget.value); + _editor.addListener(_handleTextChange); } } } diff --git a/lib/src/cupertino/spin_box.dart b/lib/src/cupertino/spin_box.dart index 68d9bdc..aeb9373 100644 --- a/lib/src/cupertino/spin_box.dart +++ b/lib/src/cupertino/spin_box.dart @@ -23,6 +23,7 @@ import 'package:flutter/cupertino.dart'; import '../base_spin_box.dart'; +import '../spin_controller.dart'; import 'spin_button.dart'; part 'third_party/default_rounded_border.dart'; @@ -80,10 +81,8 @@ class CupertinoSpinBox extends BaseSpinBox { this.enableInteractiveSelection = true, this.spacing = 8, this.onChanged, - this.canChange, - this.beforeChange, - this.afterChange, this.focusNode, + this.controller, }) : assert(min <= max), keyboardType = keyboardType ?? TextInputType.numberWithOptions( @@ -199,18 +198,13 @@ class CupertinoSpinBox extends BaseSpinBox { @override final FocusNode? focusNode; - /// Called when the user has changed the value. - @override - final ValueChanged? onChanged; - + /// Controls the spinbox. @override - final bool Function(double value)? canChange; - - @override - final VoidCallback? beforeChange; + final SpinController? controller; + /// Called when the user has changed the value. @override - final VoidCallback? afterChange; + final ValueChanged? onChanged; /// See [CupertinoTextField.enabled]. final bool enabled; @@ -267,7 +261,7 @@ class _CupertinoSpinBoxState extends State with SpinBoxMixin { final textField = CallbackShortcuts( bindings: bindings, child: CupertinoTextField( - controller: controller, + controller: editor, style: widget.textStyle, textAlign: widget.textAlign, keyboardType: widget.keyboardType, @@ -315,19 +309,19 @@ class _CupertinoSpinBoxState extends State with SpinBoxMixin { final incrementButton = CupertinoSpinButton( step: widget.step, icon: widget.incrementIcon, - enabled: widget.enabled && value < widget.max, + enabled: widget.enabled && controller.value < controller.max, interval: widget.interval, acceleration: widget.acceleration, - onStep: (step) => setValue(value + step), + onStep: (step) => controller.value += step, ); final decrementButton = CupertinoSpinButton( step: widget.step, icon: widget.decrementIcon, - enabled: widget.enabled && value > widget.min, + enabled: widget.enabled && controller.value > controller.min, interval: widget.interval, acceleration: widget.acceleration, - onStep: (step) => setValue(value - step), + onStep: (step) => controller.value -= step, ); if (isHorizontal) { diff --git a/lib/src/material/spin_box.dart b/lib/src/material/spin_box.dart index 3a92622..b938486 100644 --- a/lib/src/material/spin_box.dart +++ b/lib/src/material/spin_box.dart @@ -25,6 +25,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import '../base_spin_box.dart'; +import '../spin_controller.dart'; import 'spin_box_theme.dart'; import 'spin_button.dart'; @@ -81,10 +82,8 @@ class SpinBox extends BaseSpinBox { this.enableInteractiveSelection = true, this.spacing = 8, this.onChanged, - this.canChange, - this.beforeChange, - this.afterChange, this.focusNode, + this.controller, }) : assert(min <= max), keyboardType = keyboardType ?? TextInputType.numberWithOptions( @@ -197,18 +196,13 @@ class SpinBox extends BaseSpinBox { @override final FocusNode? focusNode; - /// Called when the user has changed the value. - @override - final ValueChanged? onChanged; - - @override - final bool Function(double value)? canChange; - + /// Controls the spinbox. @override - final VoidCallback? beforeChange; + final SpinController? controller; + /// Called when the user has changed the value. @override - final VoidCallback? afterChange; + final ValueChanged? onChanged; /// See [TextField.enabled]. final bool enabled; @@ -262,7 +256,7 @@ class SpinBox extends BaseSpinBox { class _SpinBoxState extends State with SpinBoxMixin { Color _activeColor(ThemeData theme) { - if (hasFocus) { + if (focusNode.hasFocus) { switch (theme.brightness) { case Brightness.dark: return theme.colorScheme.secondary; @@ -275,7 +269,7 @@ class _SpinBoxState extends State with SpinBoxMixin { Color? _iconColor(ThemeData theme, String? errorText) { if (!widget.enabled) return theme.disabledColor; - if (hasFocus && errorText == null) return _activeColor(theme); + if (focusNode.hasFocus && errorText == null) return _activeColor(theme); switch (theme.brightness) { case Brightness.dark: @@ -308,7 +302,7 @@ class _SpinBoxState extends State with SpinBoxMixin { .applyDefaults(theme.inputDecorationTheme); final errorText = - decoration.errorText ?? widget.validator?.call(controller.text); + decoration.errorText ?? widget.validator?.call(editor.text); final iconColor = widget.iconColor ?? spinBoxTheme?.iconColor ?? @@ -316,15 +310,19 @@ class _SpinBoxState extends State with SpinBoxMixin { final states = { if (!widget.enabled) MaterialState.disabled, - if (hasFocus) MaterialState.focused, + if (focusNode.hasFocus) MaterialState.focused, if (errorText != null) MaterialState.error, }; final decrementStates = Set.of(states); - if (value <= widget.min) decrementStates.add(MaterialState.disabled); + if (controller.value <= controller.min) { + decrementStates.add(MaterialState.disabled); + } final incrementStates = Set.of(states); - if (value >= widget.max) incrementStates.add(MaterialState.disabled); + if (controller.value >= controller.max) { + incrementStates.add(MaterialState.disabled); + } var bottom = 0.0; final isHorizontal = widget.direction == Axis.horizontal; @@ -385,7 +383,7 @@ class _SpinBoxState extends State with SpinBoxMixin { final textField = CallbackShortcuts( bindings: bindings, child: TextField( - controller: controller, + controller: editor, style: widget.textStyle, textAlign: widget.textAlign, textDirection: widget.textDirection, @@ -410,10 +408,10 @@ class _SpinBoxState extends State with SpinBoxMixin { step: widget.step, color: iconColor.resolve(incrementStates), icon: widget.incrementIcon, - enabled: widget.enabled && value < widget.max, + enabled: widget.enabled && controller.value < controller.max, interval: widget.interval, acceleration: widget.acceleration, - onStep: (step) => setValue(value + step), + onStep: (step) => controller.value += step, ); if (!widget.showButtons) return textField; @@ -422,10 +420,10 @@ class _SpinBoxState extends State with SpinBoxMixin { step: widget.step, color: iconColor.resolve(decrementStates), icon: widget.decrementIcon, - enabled: widget.enabled && value > widget.min, + enabled: widget.enabled && controller.value > controller.min, interval: widget.interval, acceleration: widget.acceleration, - onStep: (step) => setValue(value - step), + onStep: (step) => controller.value -= step, ); if (isHorizontal) { diff --git a/lib/src/spin_controller.dart b/lib/src/spin_controller.dart new file mode 100644 index 0000000..5e2d81b --- /dev/null +++ b/lib/src/spin_controller.dart @@ -0,0 +1,124 @@ +// MIT License +// +// Copyright (c) 2020 J-P Nurmi +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import 'package:flutter/foundation.dart'; + +class SpinController extends ValueNotifier { + SpinController({ + required double min, + required double max, + required double value, + this.decimals = 0, + }) : _min = min, + _max = max, + super(value); + + double _min; + double _max; + + final int decimals; + + double get min => _min; + set min(double min) { + _min = min; + value = value.clamp(min, max); + } + + double get max => _max; + set max(double max) { + _max = max; + value = value.clamp(min, max); + } + + void setRange(double min, double max) { + _min = min; + _max = max; + value = value.clamp(min, max); + } + + double? parse(String text) { + if (decimals > 0) { + return double.tryParse(text); + } + return int.tryParse(text)?.toDouble(); + } + + bool canChange(double value) => true; + + @override + set value(double v) { + final newValue = v.clamp(min, max); + if (newValue != value && canChange(newValue)) { + super.value = newValue; + } + } + + bool validate(String text) { + if (text.isEmpty) { + return true; + } + + return _validateSign(text) && + _validateRange(text) && + _validateDecimalPoint(text); + } + + bool _validateSign(String text) { + final minus = text.startsWith('-'); + if (minus && min >= 0) { + return false; + } + + final plus = text.startsWith('+'); + if (plus && max < 0) { + return false; + } + + return true; + } + + bool _validateRange(String text) { + final value = parse(text); + if (value == null) { + return false; + } + + if (value >= min && value <= max) { + return true; + } + + if (value >= 0) { + return value <= max; + } else { + return value >= min; + } + } + + bool _validateDecimalPoint(String text) { + final dot = text.lastIndexOf('.'); + if (dot >= 0 && decimals < text.substring(dot + 1).length) { + return false; + } + + return true; + } +} diff --git a/lib/src/spin_formatter.dart b/lib/src/spin_formatter.dart index 1806e6f..044dece 100644 --- a/lib/src/spin_formatter.dart +++ b/lib/src/spin_formatter.dart @@ -22,66 +22,28 @@ import 'package:flutter/services.dart'; +import 'spin_controller.dart'; + // ignore_for_file: public_member_api_docs class SpinFormatter extends TextInputFormatter { - SpinFormatter({required this.min, required this.max, required this.decimals}); - - final double min; - final double max; - final int decimals; - - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, TextEditingValue newValue) { - final input = newValue.text; - if (input.isEmpty) { - return newValue; - } - - final minus = input.startsWith('-'); - if (minus && min >= 0) { - return oldValue; - } - - final plus = input.startsWith('+'); - if (plus && max < 0) { - return oldValue; - } + SpinFormatter(this._controller); - if ((minus || plus) && input.length == 1) { - return newValue; - } + final SpinController _controller; - if (decimals <= 0 && !_validateValue(int.tryParse(input))) { - return oldValue; - } - - if (decimals > 0 && !_validateValue(double.tryParse(input))) { - return oldValue; - } - - final dot = input.lastIndexOf('.'); - if (dot >= 0 && decimals < input.substring(dot + 1).length) { - return oldValue; - } - - return newValue; + String formatValue( + double value, { + required int decimals, + required int digits, + }) { + return value.toStringAsFixed(decimals).padLeft(digits, '0'); } - bool _validateValue(num? value) { - if (value == null) { - return false; - } - - if (value >= min && value <= max) { - return true; - } - - if (value >= 0) { - return value <= max; - } else { - return value >= min; - } + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return _controller.validate(newValue.text) ? newValue : oldValue; } } diff --git a/test/test_spinbox.dart b/test/test_spinbox.dart index 1ab4e9c..d502b4e 100644 --- a/test/test_spinbox.dart +++ b/test/test_spinbox.dart @@ -240,7 +240,7 @@ void testInput(TestBuilder builder) { tester.testTextInput.updateEditingValue(TextEditingValue.empty); await tester.idle(); - expect(tester.state(find.byType(S)), hasValue(0)); + expect(tester.state(find.byType(S)), hasValue(1)); expect(find.editableText, hasText('')); await tester.testTextInput.receiveAction(TextInputAction.done); @@ -257,7 +257,8 @@ void testInput(TestBuilder builder) { tester.testTextInput.updateEditingValue(TextEditingValue.empty); await tester.idle(); - expect(tester.state(find.byType(S)), hasValue(0)); + expect(tester.state(find.byType(S)), hasValue(1)); + expect(find.editableText, hasText('')); find.editableText.focusNode.unfocus(); await tester.idle(); @@ -280,15 +281,15 @@ void testRange(TestBuilder builder) { tester.testTextInput.enterText('9'); await tester.idle(); - expect(tester.state(find.byType(S)), hasValue(9)); + expect(tester.state(find.byType(S)), hasValue(20)); expect(find.editableText, hasNoSelection); expect(find.editableText, hasText('9')); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.idle(); expect(find.editableText, hasNoFocus); - expect(tester.state(find.byType(S)), hasValue(20)); - expect(find.editableText, hasText('20')); + expect(tester.state(find.byType(S)), hasValue(10)); + expect(find.editableText, hasText('10')); }); testWidgets('max', (tester) async { diff --git a/test/test_utils.dart b/test/test_utils.dart index 1f76666..d4e8bda 100644 --- a/test/test_utils.dart +++ b/test/test_utils.dart @@ -59,5 +59,5 @@ class HasValueMatcher extends CustomMatcher { @override // ### TODO: make BaseSpinBoxState a mixin? // ignore: avoid_dynamic_calls - Object? featureValueOf(dynamic state) => state.value; + Object? featureValueOf(dynamic state) => state.controller.value; }