Skip to content

Commit 575e59c

Browse files
aygursthelukewaltonCopilot
authored
feat(UX-1355): Make input field on slider component (#403)
Co-authored-by: thelukewalton <[email protected]> Co-authored-by: Luke Walton <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 531c3f5 commit 575e59c

File tree

7 files changed

+204
-37
lines changed

7 files changed

+204
-37
lines changed

example/lib/home.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ final List<Component> components = [
113113
Component(SegmentedControlExample.name, (context) => const SegmentedControlExample()),
114114
Component(SelectInputExample.name, (context) => const SelectInputExample()),
115115
Component(SliderExample.name, (context) => const SliderExample()),
116+
Component(SliderInputField.name, (context) => const SliderInputField()),
116117
Component(SnackBarExample.name, (context) => const SnackBarExample()),
117118
Component(StatusChipExample.name, (context) => const StatusChipExample()),
118119
Component(StatusLabel.name, (context) => const StatusLabel()),

example/lib/pages/components/slider_example.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,42 @@ class _SliderExampleState extends State<SliderExample> {
3535
);
3636
}
3737
}
38+
39+
class SliderInputField extends StatefulWidget {
40+
static const String name = 'Slider/SliderInputField';
41+
42+
const SliderInputField({super.key});
43+
44+
@override
45+
State<SliderInputField> createState() => _SliderInputFieldState();
46+
}
47+
48+
class _SliderInputFieldState extends State<SliderInputField> {
49+
double value1 = 50;
50+
double value2 = 50;
51+
52+
@override
53+
Widget build(BuildContext context) {
54+
return ExampleScaffold(
55+
name: SliderInputField.name,
56+
children: [
57+
ZetaSlider(
58+
value: value1,
59+
divisions: 5,
60+
inputField: true,
61+
),
62+
ZetaSlider(
63+
value: value1,
64+
onChange: (newValue) => setState(() => value1 = newValue),
65+
inputField: true,
66+
),
67+
ZetaSlider(
68+
value: value2,
69+
divisions: 5,
70+
onChange: (newValue) => setState(() => value2 = newValue),
71+
inputField: true,
72+
),
73+
],
74+
);
75+
}
76+
}

packages/zeta_flutter/lib/src/components/slider/slider.dart

Lines changed: 150 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import 'package:flutter/foundation.dart';
22
import 'package:flutter/material.dart';
3+
import 'package:flutter/services.dart';
34

45
import '../../../zeta_flutter.dart';
56

6-
// TODO(UX-1355): Create slider input field.
7-
87
/// Sliders allow users to make selections from a range of values.
98
///
109
/// Figma: https://www.figma.com/design/JesXQFLaPJLc1BdBM4sisI/%F0%9F%A6%93-ZDS---Components?node-id=875-11860&node-type=canvas&m=dev
@@ -20,12 +19,13 @@ class ZetaSlider extends ZetaStatefulWidget {
2019
this.divisions,
2120
this.semanticLabel,
2221
this.min = 0.0,
23-
this.max = 1.0,
22+
this.max = 100.0,
23+
this.inputField = false,
2424
});
2525

2626
/// Double value to represent slider percentage.
2727
///
28-
/// Default [min] / [max] are 0.0 and 1.0 respectively; this value should be between [min] and [max].
28+
/// Default [min] / [max] are 0.0 and 100.0 respectively; this value should be between [min] and [max].
2929
final double value;
3030

3131
/// Callback to handle changing of slider
@@ -45,6 +45,13 @@ class ZetaSlider extends ZetaStatefulWidget {
4545
/// Maximum value of the slider.
4646
final double max;
4747

48+
/// Whether to show an input field to the right of the slider.
49+
/// The input field will change the slider value when updated.
50+
///
51+
/// This also adds the min and max values below the slider.
52+
/// This will default to 0 - 100 if min and max are not changed.
53+
final bool inputField;
54+
4855
@override
4956
State<ZetaSlider> createState() => _ZetaSliderState();
5057
@override
@@ -57,24 +64,81 @@ class ZetaSlider extends ZetaStatefulWidget {
5764
..add(IntProperty('divisions', divisions))
5865
..add(StringProperty('semanticLabel', semanticLabel))
5966
..add(DoubleProperty('max', max))
60-
..add(DoubleProperty('min', min));
67+
..add(DoubleProperty('min', min))
68+
..add(DiagnosticsProperty<bool>('inputField', inputField));
6169
}
6270
}
6371

6472
class _ZetaSliderState extends State<ZetaSlider> {
6573
bool _selected = false;
74+
final _inputController = TextEditingController();
75+
Debounce? _debounce;
76+
77+
@override
78+
void initState() {
79+
super.initState();
80+
_inputController.text = widget.value.toString();
81+
_inputController.addListener(_updateTextField);
82+
}
83+
84+
// Called when slider is updated, changes text field value.
85+
void _updateTextFieldFromSlider(double sliderValue) {
86+
_inputController.text = sliderValue.toInt().toString();
87+
}
88+
89+
// Called when text field is updated, changes slider value.
90+
void _updateTextField() {
91+
_debounce?.cancel();
92+
_debounce = Debounce(
93+
() {
94+
final number = int.tryParse(_inputController.text);
95+
if (number != null) {
96+
final num newValue;
97+
if (widget.divisions != null) {
98+
final divisionSize = (widget.max - widget.min) / widget.divisions!;
99+
final snappedValue = ((number - widget.min) / divisionSize).round() * divisionSize + widget.min;
100+
newValue = snappedValue.round().clamp(widget.min, widget.max);
101+
if (number != newValue) {
102+
_inputController.text = newValue.toString();
103+
}
104+
} else {
105+
newValue = number.clamp(widget.min, widget.max).toInt();
106+
if (number != newValue) {
107+
_inputController.text = newValue.toString();
108+
}
109+
}
110+
widget.onChange?.call(newValue.toDouble());
111+
}
112+
},
113+
duration: const Duration(milliseconds: 200),
114+
);
115+
}
116+
117+
@override
118+
void dispose() {
119+
_debounce?.cancel();
120+
_inputController.dispose();
121+
super.dispose();
122+
}
66123

67124
@override
68125
Widget build(BuildContext context) {
69-
final colors = Zeta.of(context).colors;
126+
final zeta = Zeta.of(context);
127+
final colors = zeta.colors;
128+
129+
final activeColor = widget.onChange == null
130+
? colors.mainDisabled
131+
: _selected
132+
? colors.mainPrimary
133+
: colors.mainDefault;
70134

71135
return MergeSemantics(
72136
child: Semantics(
73137
label: widget.semanticLabel,
74138
child: SliderTheme(
75139
data: SliderThemeData(
76140
/// Active Track
77-
activeTrackColor: _activeColor,
141+
activeTrackColor: activeColor,
78142
disabledActiveTrackColor: colors.mainDisabled,
79143

80144
/// Inactive Track
@@ -90,45 +154,98 @@ class _ZetaSliderState extends State<ZetaSlider> {
90154
thumbColor: colors.mainDefault,
91155
disabledThumbColor: colors.mainDisabled,
92156
overlayShape: _SliderThumb(
93-
size: Zeta.of(context).spacing.xl / 2,
157+
size: zeta.spacing.xl / 2,
94158
rounded: context.rounded,
95-
color: _activeColor,
159+
color: activeColor,
96160
),
97161
thumbShape: _SliderThumb(
98-
size: Zeta.of(context).spacing.large / 2,
162+
size: zeta.spacing.large / 2,
99163
rounded: context.rounded,
100-
color: _activeColor,
164+
color: activeColor,
101165
),
102166
trackShape: context.rounded ? _RoundedRectangleTrackShape() : const RectangularSliderTrackShape(),
103167
),
104-
child: Slider(
105-
value: widget.value,
106-
onChanged: widget.onChange,
107-
divisions: widget.divisions,
108-
onChangeStart: (_) {
109-
setState(() {
110-
_selected = true;
111-
});
112-
},
113-
onChangeEnd: (_) {
114-
setState(() {
115-
_selected = false;
116-
});
117-
},
118-
min: widget.min,
119-
max: widget.max,
120-
),
168+
child: _generateSlider(),
121169
),
122170
),
123171
);
124172
}
125173

126-
Color get _activeColor {
127-
final colors = Zeta.of(context).colors;
128-
if (widget.onChange == null) {
129-
return colors.mainDisabled;
174+
Widget _generateCoreSlider() {
175+
return Slider(
176+
value: widget.value,
177+
onChanged: widget.inputField
178+
? widget.onChange != null
179+
? (value) {
180+
// Update text field when slider changes
181+
_updateTextFieldFromSlider(value);
182+
widget.onChange?.call(value);
183+
}
184+
: null
185+
: widget.onChange,
186+
divisions: widget.divisions,
187+
onChangeStart: (_) => setState(() => _selected = true),
188+
onChangeEnd: (_) => setState(() => _selected = false),
189+
min: widget.min,
190+
max: widget.max,
191+
);
192+
}
193+
194+
// Private function to generate slider depending on input field boolean.
195+
Widget _generateSlider() {
196+
final zeta = Zeta.of(context);
197+
final colors = zeta.colors;
198+
199+
// If input field is true, return slider with input field.
200+
if (widget.inputField) {
201+
return Row(
202+
spacing: zeta.spacing.large,
203+
children: [
204+
Expanded(
205+
child: Column(
206+
spacing: zeta.spacing.small,
207+
children: [
208+
//Slider
209+
_generateCoreSlider(),
210+
//Numbers
211+
Row(
212+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
213+
children: [
214+
Text(
215+
widget.min.toInt().toString(),
216+
style: zeta.textStyles.bodyMedium.apply(
217+
color: widget.onChange == null ? colors.mainDisabled : colors.mainDefault,
218+
),
219+
),
220+
Text(
221+
widget.max.toInt().toString(),
222+
style: zeta.textStyles.bodyMedium.apply(
223+
color: widget.onChange == null ? colors.mainDisabled : colors.mainDefault,
224+
),
225+
),
226+
],
227+
).paddingHorizontal(zeta.spacing.minimum),
228+
],
229+
),
230+
),
231+
//Text Input
232+
SizedBox(
233+
width: zeta.spacing.xl_8 + zeta.spacing.small,
234+
child: ZetaTextInput(
235+
keyboardType: TextInputType.number,
236+
disabled: widget.onChange == null,
237+
size: ZetaWidgetSize.large,
238+
textAlign: TextAlign.center,
239+
inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly],
240+
controller: _inputController,
241+
),
242+
),
243+
],
244+
);
130245
}
131-
return _selected ? colors.mainPrimary : colors.mainDefault;
246+
247+
// If input field is false, return just the slider.
248+
return _generateCoreSlider();
132249
}
133250
}
134251

packages/zeta_flutter/lib/src/components/text_input/internal_text_input.dart

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class InternalTextInput extends ZetaStatefulWidget {
3939
this.borderRadius,
4040
this.textInputAction,
4141
this.constrained = false,
42+
this.textAlign = TextAlign.start,
4243
}) : requirementLevel = requirementLevel ?? ZetaFormFieldRequirement.none,
4344
assert(prefix == null || prefixText == null, 'Only one of prefix or prefixText can be accepted.'),
4445
assert(suffix == null || suffixText == null, 'Only one of suffix or suffixText can be accepted.');
@@ -139,6 +140,10 @@ class InternalTextInput extends ZetaStatefulWidget {
139140
/// Determines if the prefix and suffix should be constrained.
140141
final bool constrained;
141142

143+
/// The text alignment within the input.
144+
/// {@macro flutter.widgets.editableText.textAlign}
145+
final TextAlign textAlign;
146+
142147
@override
143148
State<InternalTextInput> createState() => InternalTextInputState();
144149
@override
@@ -167,7 +172,8 @@ class InternalTextInput extends ZetaStatefulWidget {
167172
..add(DiagnosticsProperty<BorderRadius?>('borderRadius', borderRadius))
168173
..add(StringProperty('semanticLabel', semanticLabel))
169174
..add(EnumProperty<TextInputAction?>('textInputAction', textInputAction))
170-
..add(DiagnosticsProperty<bool>('constrained', constrained));
175+
..add(DiagnosticsProperty<bool>('constrained', constrained))
176+
..add(EnumProperty<TextAlign>('textAlign', textAlign));
171177
}
172178
}
173179

@@ -352,6 +358,7 @@ class InternalTextInputState extends State<InternalTextInput> {
352358
keyboardType: widget.keyboardType,
353359
inputFormatters: widget.inputFormatters,
354360
textAlignVertical: TextAlignVertical.center,
361+
textAlign: widget.textAlign,
355362
onChanged: widget.onChange,
356363
onSubmitted: widget.onSubmit,
357364
style: _baseTextStyle,

packages/zeta_flutter/lib/src/components/text_input/text_input.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class ZetaTextInput extends ZetaTextFormField {
4848
this.keyboardType,
4949
this.focusNode,
5050
this.semanticLabel,
51+
TextAlign textAlign = TextAlign.start,
5152
}) : assert(initialValue == null || controller == null, 'Only one of initial value and controller can be accepted.'),
5253
assert(prefix == null || prefixText == null, 'Only one of prefix or prefixText can be accepted.'),
5354
assert(suffix == null || suffixText == null, 'Only one of suffix or suffixText can be accepted.'),
@@ -77,6 +78,7 @@ class ZetaTextInput extends ZetaTextFormField {
7778
keyboardType: keyboardType,
7879
focusNode: focusNode,
7980
semanticLabel: semanticLabel,
81+
textAlign: textAlign,
8082
);
8183
},
8284
);

packages/zeta_flutter/test/src/components/slider/slider_test.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ void main() {
3030

3131
group('Interaction Tests', () {
3232
testWidgets('ZetaSlider min/max values', (WidgetTester tester) async {
33-
const double sliderValue = 0.5;
33+
const double sliderValue = 50;
3434
double? changedValue;
3535

3636
await tester.pumpWidget(
@@ -46,15 +46,15 @@ void main() {
4646

4747
final slider = tester.widget<Slider>(find.byType(Slider));
4848
expect(slider.min, 0.0);
49-
expect(slider.max, 1.0);
49+
expect(slider.max, 100.0);
5050

5151
// Drag the slider to the minimum value
5252
await tester.drag(find.byType(Slider), const Offset(-400, 0));
5353
expect(changedValue, 0.0);
5454

5555
// Drag the slider to the maximum value
5656
await tester.drag(find.byType(Slider), const Offset(400, 0));
57-
expect(changedValue, 1.0);
57+
expect(changedValue, 100.0);
5858
});
5959
});
6060

widgetbook/lib/src/components/slider.widgetbook.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Widget sliderUseCase(BuildContext context) {
2121
divisions: context.knobs.intOrNull.slider(label: 'Divisions', min: 1, initialValue: 10),
2222
onChange: context.knobs.boolean(label: 'Disabled') ? null : (newValue) => setState(() => value = newValue),
2323
rounded: context.knobs.boolean(label: 'Rounded'),
24+
inputField: context.knobs.boolean(label: 'Input Field'),
2425
);
2526
},
2627
);

0 commit comments

Comments
 (0)