Skip to content

Commit ca4903e

Browse files
Support passing disabled prop to custom Slider elements
Disabled prop allows to customize the disabled styling of custom track, thumb and steps
1 parent 7efdb25 commit ca4903e

5 files changed

Lines changed: 231 additions & 18 deletions

File tree

‎package/__test__/Slider.test.tsx‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,27 @@ import React from "react";
22
import {Text, ViewProps} from "react-native";
33
import {act, fireEvent, render, within} from "@testing-library/react-native";
44
import Slider from "../src/Slider";
5-
import type {StepsProps, ThumbProps} from "../typings";
5+
import type {StepsProps, ThumbProps, TrackProps} from "../typings";
66

77
const StepThumb = ({index}: ThumbProps) => <Text>{`step ${index}`}</Text>;
88

99
const StepMarker = ({index, value, marked}: StepsProps) => (
1010
<Text>{`marker ${index}: ${value}${marked ? " marked" : ""}`}</Text>
1111
);
1212

13+
/** The three custom elements, each saying out loud whether it was told off. */
14+
const TrackThatReports = ({disabled}: TrackProps) => (
15+
<Text>{`track${disabled ? " disabled" : ""}`}</Text>
16+
);
17+
18+
const ThumbThatReports = ({index, disabled}: ThumbProps) => (
19+
<Text>{`step ${index}${disabled ? " disabled" : ""}`}</Text>
20+
);
21+
22+
const MarkerThatReports = ({index, disabled}: StepsProps) => (
23+
<Text>{`marker ${index}${disabled ? " disabled" : ""}`}</Text>
24+
);
25+
1326
const markerTexts = (texts: {props: {children?: unknown}}[]) =>
1427
texts.map(({props}) => props.children);
1528

@@ -95,6 +108,96 @@ describe("Slider", () => {
95108
expect(slider.props.valueLeft).toBe(10);
96109
expect(slider.props.valueRight).toBe(40);
97110
});
111+
112+
it("Tells a custom track and thumb that the Slider is switched off", async () => {
113+
const {getByText} = await render(
114+
<Slider
115+
disabled
116+
maximumValue={1}
117+
step={1}
118+
track={TrackThatReports}
119+
thumb={ThumbThatReports}
120+
/>,
121+
);
122+
123+
expect(getByText("track disabled")).toBeTruthy();
124+
expect(getByText("step 0 disabled")).toBeTruthy();
125+
});
126+
127+
it("Tells every custom step marker that the Slider is switched off", async () => {
128+
const {getAllByText} = await render(
129+
<Slider
130+
disabled
131+
maximumValue={2}
132+
step={1}
133+
stepMarkers={MarkerThatReports}
134+
/>,
135+
);
136+
137+
expect(markerTexts(getAllByText(/^marker/))).toEqual([
138+
"marker 0 disabled",
139+
"marker 1 disabled",
140+
"marker 2 disabled",
141+
]);
142+
});
143+
144+
it("Tells both thumbs of a switched-off ranged Slider", async () => {
145+
const {getAllByText} = await render(
146+
<Slider disabled ranged step={1} thumb={ThumbThatReports} />,
147+
);
148+
149+
expect(markerTexts(getAllByText(/^step/))).toEqual([
150+
"step 0 disabled",
151+
"step 1 disabled",
152+
]);
153+
});
154+
155+
it("Leaves a switched-on Slider's custom elements uninformed", async () => {
156+
const {queryByText, getByText} = await render(
157+
<Slider
158+
maximumValue={1}
159+
step={1}
160+
track={TrackThatReports}
161+
thumb={ThumbThatReports}
162+
stepMarkers={MarkerThatReports}
163+
/>,
164+
);
165+
166+
expect(queryByText("track disabled")).toBeNull();
167+
expect(queryByText("step 0 disabled")).toBeNull();
168+
expect(queryByText("marker 0 disabled")).toBeNull();
169+
expect(getByText("track")).toBeTruthy();
170+
});
171+
172+
it("Switches the custom elements off and back on with the Slider", async () => {
173+
const {queryByText, getByText, rerender} = await render(
174+
<Slider maximumValue={1} step={1} thumb={ThumbThatReports} />,
175+
);
176+
177+
expect(getByText("step 0")).toBeTruthy();
178+
179+
await act(async () => {
180+
await rerender(
181+
<Slider
182+
disabled
183+
maximumValue={1}
184+
step={1}
185+
thumb={ThumbThatReports}
186+
/>,
187+
);
188+
});
189+
190+
expect(getByText("step 0 disabled")).toBeTruthy();
191+
192+
await act(async () => {
193+
await rerender(
194+
<Slider maximumValue={1} step={1} thumb={ThumbThatReports} />,
195+
);
196+
});
197+
198+
expect(queryByText("step 0 disabled")).toBeNull();
199+
expect(getByText("step 0")).toBeTruthy();
200+
});
98201
});
99202

100203
describe("Custom thumb", () => {

‎package/android/src/main/java/com/reactnativecommunity/slider/RNCSliderView.kt‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
335335

336336
customThumbCount = minOf(thumbViews.size, MAXIMUM_THUMB_COUNT)
337337
placeStepMarkerContainers()
338+
applyDisabledLook()
338339
}
339340

340341
/**
@@ -491,13 +492,40 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
491492
*
492493
* The values themselves are no part of this, so their setters keep taking them while
493494
* the slider is switched off - it only stops showing where they put its thumbs.
495+
*
496+
* The views JS renders in place of the track, the thumbs and the step markers are
497+
* switched off along with the rest of the slider - see [applyDisabledLook].
494498
*/
495499
fun setDisabled(value: Boolean) {
496500
disabledValue = value
497501

498502
if (value) {
499503
finishSliding()
500504
}
505+
506+
applyDisabledLook()
507+
}
508+
509+
/**
510+
* Draws the views JS renders in place of the track, of a thumb and of the step markers
511+
* at the faintness of a control nobody can reach.
512+
*
513+
* The slider draws itself in Material 3's own disabled colours once it is switched off,
514+
* but a view JS renders is JS's own drawing, mounted over it, and nothing here reaches
515+
* it as a state to draw itself by. So the whole of a switched-off slider goes faint
516+
* together, the views JS gave included - over whatever JS drew into them, which is why
517+
* one that dims itself for being switched off ends up dimmed twice over.
518+
*
519+
* Run over again whenever the children are handed out afresh: a container made or
520+
* remounted once the slider is already switched off would otherwise stay as bright as
521+
* the slider was before it was.
522+
*/
523+
private fun applyDisabledLook() {
524+
val alpha = if (disabledValue) DISABLED_CHILD_ALPHA else 1f
525+
526+
trackContainer.alpha = alpha
527+
thumbContainers.forEach { container -> container.alpha = alpha }
528+
stepMarkerContainers.forEach { container -> container.alpha = alpha }
501529
}
502530

503531
fun setMinimumTrackColor(value: Int?) {
@@ -1152,6 +1180,14 @@ class RNCSliderView(context: Context) : FrameLayout(context) {
11521180

11531181
/** How far a step marker keeps from the thumb. */
11541182
const val STEP_MARKER_GAP_DP = 4f
1183+
1184+
/**
1185+
* How faint a view JS renders in place of the track, a thumb or a step marker is
1186+
* drawn once the slider is switched off - the alpha Material 3 dims a switched-off
1187+
* control's own content to, so that the views JS gave go faint along with the
1188+
* slider they sit on rather than against it.
1189+
*/
1190+
const val DISABLED_CHILD_ALPHA = 0.38f
11551191
}
11561192
}
11571193

‎package/ios/RNCSliderView.swift‎

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ final class RNCSliderModel: ObservableObject {
9393
return thumbValues.contains { abs($0 - stepValue) <= step * Self.stepTolerance }
9494
}
9595

96+
/// How faint a slider switched off is drawn, at the faintness a control that cannot be
97+
/// reached is drawn at. Shared by the slider `RNCSliderContent` hosts and by the views
98+
/// JS renders over it, so that the two go faint together - see
99+
/// `RNCSliderView.applyDisabledLook`.
100+
static let disabledAlpha: CGFloat = 0.3
101+
96102
/// Leeway, as a fraction of a step, for a value to count as landing on one.
97103
static let stepTolerance = 1e-9
98104

@@ -303,11 +309,23 @@ struct RNCSliderGeometry {
303309
rightOffset = CGFloat(Self.fraction(of: model.valueRight, in: range)) * travel
304310
}
305311

312+
/// How far along the slider the centre of the thumb whose near edge sits at the given
313+
/// offset stands.
314+
///
315+
/// The offsets place a thumb by its near edge, while a thumb covers the track around
316+
/// its *centre* - so that is where the parts of the track have to be cut into, and not
317+
/// at the near edge. Cutting at the near edge leaves the strip between the two on show,
318+
/// which is only ever covered again by a thumb drawn of its own: a thumb JS replaces
319+
/// draws no track, and leaves that strip as a gap of unfilled track beside it.
320+
func thumbCenterOffset(at offset: CGFloat) -> CGFloat {
321+
offset + thumbLength / 2
322+
}
323+
306324
// Centre of the thumb whose near edge sits the given distance along the slider, in
307325
// the coordinates of the view. The thumbs lie along the middle of the view, and a
308326
// vertical slider grows upwards.
309327
func thumbCenter(atOffset offset: CGFloat) -> CGPoint {
310-
let along = offset + thumbLength / 2
328+
let along = thumbCenterOffset(at: offset)
311329

312330
return vertical
313331
? CGPoint(x: size.width / 2, y: size.height - along)
@@ -322,7 +340,8 @@ struct RNCSliderGeometry {
322340
/// being the middle of their left edge - they are not turned round with the slider,
323341
/// which keeps whatever they read the right way up.
324342
func stepMarkerAnchor(at value: Double) -> CGPoint {
325-
let along = CGFloat(Self.fraction(of: value, in: range)) * travel + thumbLength / 2
343+
let nearEdge = CGFloat(Self.fraction(of: value, in: range)) * travel
344+
let along = thumbCenterOffset(at: nearEdge)
326345
let across = breadth / 2 + thumbThickness / 2 + Self.stepMarkerGap
327346

328347
return vertical
@@ -396,7 +415,8 @@ struct RNCSliderGeometry {
396415
let offset = offset(of: point)
397416

398417
return thumbs.min {
399-
abs($0.offset + thumbLength / 2 - offset) < abs($1.offset + thumbLength / 2 - offset)
418+
abs(thumbCenterOffset(at: $0.offset) - offset)
419+
< abs(thumbCenterOffset(at: $1.offset) - offset)
400420
}?.thumb
401421
}
402422

@@ -461,14 +481,11 @@ struct RNCSliderContent: View {
461481
// and their places. The values stand wherever the props put them, so nothing here
462482
// moves.
463483
//
464-
// Only the slider this view draws is washed out. A track, a thumb or a step marker
465-
// JS rendered is its own view, outside this one, and stays as bright as JS drew it.
466-
.opacity(model.disabled ? Self.disabledOpacity : 1)
484+
// What is washed out here is only the slider this view draws: a track, a thumb or a
485+
// step marker JS rendered is mounted over it, as a view of its own, and is dimmed
486+
// to this same faintness there - see `RNCSliderView.applyDisabledLook`.
487+
.opacity(model.disabled ? Double(RNCSliderModel.disabledAlpha) : 1)
467488
}
468-
469-
/// How faint a slider switched off is drawn, at the faintness a control that cannot be
470-
/// reached is drawn at.
471-
private static let disabledOpacity: Double = 0.3
472489
}
473490

474491
struct RNCSingleSliderContent: View {
@@ -524,7 +541,14 @@ struct RNCRangedSliderContent: View {
524541
let lowerColor = tint(model.minimumTrackColor, or: model.ranged ? Self.trackColor : .accentColor)
525542
let middleColor = tint(model.middleRangeTrackColor, or: .accentColor)
526543
let upperColor = tint(model.maximumTrackColor, or: Self.trackColor)
527-
let upperStart = model.ranged ? geometry.rightOffset : geometry.leftOffset
544+
// The parts of the track are cut where the thumbs cover it, which is at their
545+
// centre and not at the near edge the offsets give: cutting any earlier leaves a
546+
// strip of the track left untinted beside a thumb - unmistakable once JS replaces
547+
// the thumb with a view that draws none of its own.
548+
let lowerEnd = geometry.thumbCenterOffset(at: geometry.leftOffset)
549+
let upperStart = geometry.thumbCenterOffset(
550+
at: model.ranged ? geometry.rightOffset : geometry.leftOffset
551+
)
528552

529553
// Everything is placed by how far along the slider it sits, so the stack is
530554
// anchored at the end the minimum value is at: the leading edge across the view,
@@ -533,13 +557,15 @@ struct RNCRangedSliderContent: View {
533557
if !model.customTrack {
534558
track(Self.trackColor, length: nil)
535559

536-
track(lowerColor, length: geometry.leftOffset)
560+
track(lowerColor, length: lowerEnd)
537561

538-
// Drawn between the two thumb centres, which is why it is inset by half a
539-
// thumb. `max` keeps it from inverting on values crossed by JS.
562+
// Drawn between the two thumb centres, which is where the parts of the track
563+
// are cut - so its length is the gap between the near edges the offsets give,
564+
// and it is inset by half a thumb from the nearer of them. `max` keeps it from
565+
// inverting on values crossed by JS.
540566
if model.ranged {
541567
track(middleColor, length: max(geometry.rightOffset - geometry.leftOffset, 0))
542-
.offset(offsetAlong(min(geometry.leftOffset, geometry.rightOffset) + geometry.thumbLength / 2))
568+
.offset(offsetAlong(geometry.thumbCenterOffset(at: min(geometry.leftOffset, geometry.rightOffset))))
543569
}
544570

545571
track(upperColor, length: geometry.length - upperStart).offset(offsetAlong(upperStart))
@@ -1105,6 +1131,9 @@ public final class RNCSliderView: UIView {
11051131
/// report: the drags of the hosted thumbs, the tap and the hold on the track and
11061132
/// VoiceOver's adjustments all hang off it, and what it is drawn as comes from the
11071133
/// same flag.
1134+
///
1135+
/// The views JS renders in place of the track, the thumbs and the step markers are
1136+
/// switched off along with the rest of the slider - see `applyDisabledLook`.
11081137
@objc public var disabled: Bool {
11091138
get { model.disabled }
11101139
set {
@@ -1118,9 +1147,30 @@ public final class RNCSliderView: UIView {
11181147
}
11191148

11201149
isUserInteractionEnabled = !newValue
1150+
applyDisabledLook()
11211151
}
11221152
}
11231153

1154+
/// Draws the views JS rendered in place of the track, of a thumb and of the step
1155+
/// markers at the faintness of a control nobody can reach.
1156+
///
1157+
/// The hosted slider is the one thing here that switches itself off to be drawn: it
1158+
/// washes out its own track and thumbs. A view JS renders is mounted over it as a view
1159+
/// of its own, outside that, and nothing reaches it as a state to draw itself by - so
1160+
/// the containers holding them are dimmed to the same faintness, and the whole of a
1161+
/// switched-off slider goes faint together, over whatever JS drew into them.
1162+
///
1163+
/// Run over again whenever the children are handed out afresh: a container made or
1164+
/// remounted once the slider is already switched off would otherwise stay as bright as
1165+
/// the slider was before it was.
1166+
private func applyDisabledLook() {
1167+
let alpha: CGFloat = model.disabled ? RNCSliderModel.disabledAlpha : 1
1168+
1169+
trackContainer.alpha = alpha
1170+
thumbContainers.forEach { $0.alpha = alpha }
1171+
stepMarkerContainers.forEach { $0.alpha = alpha }
1172+
}
1173+
11241174
/// Tints of the parts of the track - see `RNCSliderModel.minimumTrackColor`.
11251175
@objc public var minimumTrackColor: UIColor? {
11261176
get { model.minimumTrackColor }
@@ -1327,6 +1377,7 @@ public final class RNCSliderView: UIView {
13271377
}
13281378

13291379
model.customThumbCount = min(thumbViews.count, thumbContainers.count)
1380+
applyDisabledLook()
13301381
setNeedsLayout()
13311382
}
13321383

0 commit comments

Comments
 (0)