Skip to content

Commit cd909c2

Browse files
paulfthomasdsn5ft
authored andcommitted
[Slider] Add centered configuration
- add attr `centered` to enable the new centered configuration, see https://m3.material.io/components/sliders/overview#227dd2d1-8be6-4646-b8e6-f05bbac583e5 - prevent drawing the ticks in the gap spaces around the thumb and under it for better visuals PiperOrigin-RevId: 741161174
1 parent 9b44002 commit cd909c2

File tree

8 files changed

+202
-51
lines changed

8 files changed

+202
-51
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2019 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.material.catalog.slider;
18+
19+
import io.material.catalog.R;
20+
21+
import android.os.Bundle;
22+
import android.view.LayoutInflater;
23+
import android.view.View;
24+
import android.view.ViewGroup;
25+
import androidx.annotation.NonNull;
26+
import androidx.annotation.Nullable;
27+
import com.google.android.material.slider.Slider;
28+
import io.material.catalog.feature.DemoFragment;
29+
30+
/**
31+
* Fragment to display a few basic uses of the centered {@link Slider} widget for the Catalog app.
32+
*/
33+
public class SliderCenteredDemoFragment extends DemoFragment {
34+
35+
@Nullable
36+
@Override
37+
public View onCreateDemoView(
38+
@NonNull LayoutInflater layoutInflater,
39+
@Nullable ViewGroup viewGroup,
40+
@Nullable Bundle bundle) {
41+
return layoutInflater.inflate(
42+
R.layout.cat_slider_demo_centered, viewGroup, false /* attachToRoot */);
43+
}
44+
}

catalog/java/io/material/catalog/slider/SliderFragment.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ public Fragment createFragment() {
7070
return new SliderDiscreteDemoFragment();
7171
}
7272
});
73+
additionalDemos.add(
74+
new Demo(R.string.cat_slider_demo_centered_title) {
75+
@Override
76+
public Fragment createFragment() {
77+
return new SliderCenteredDemoFragment();
78+
}
79+
});
7380
additionalDemos.add(
7481
new Demo(R.string.cat_slider_demo_scroll_container_title) {
7582
@Override
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
Copyright 2025 The Android Open Source Project
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
-->
16+
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
17+
xmlns:app="http://schemas.android.com/apk/res-auto"
18+
android:layout_width="match_parent"
19+
android:layout_height="match_parent">
20+
21+
<LinearLayout
22+
android:layout_width="match_parent"
23+
android:layout_height="wrap_content"
24+
android:paddingTop="16dp"
25+
android:paddingLeft="16dp"
26+
android:paddingRight="16dp"
27+
android:orientation="vertical">
28+
29+
<com.google.android.material.slider.Slider
30+
android:layout_width="match_parent"
31+
android:layout_height="wrap_content"
32+
android:layout_gravity="center"
33+
android:stepSize="1"
34+
android:valueFrom="-5"
35+
android:valueTo="5"
36+
app:centered="true"
37+
app:tickVisible="false" />
38+
</LinearLayout>
39+
</ScrollView>

catalog/java/io/material/catalog/slider/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<string name="cat_slider_title" description="Title for the screen that showcases demonstrative usages of the Slider widget [CHAR LIMIT=NONE]">Slider</string>
1919
<string name="cat_slider_demo_continuous_title" description="Title for the continuous Slider widget demo [CHAR LIMIT=NONE]">Continuous Slider demo</string>
2020
<string name="cat_slider_demo_discrete_title" description="Title for the discrete Slider widget demo [CHAR LIMIT=NONE]">Discrete Slider demo</string>
21+
<string name="cat_slider_demo_centered_title" description="Title for the centered Slider widget demo [CHAR LIMIT=NONE]">Centered Slider demo</string>
2122
<string name="cat_slider_demo_scroll_container_title" description="Title for the Slider inside scrolling container demo [CHAR LIMIT=NONE]">Slider in scrolling container demo</string>
2223
<string name="cat_slider_demo_label_behavior_title" description="Title for the Slider with different label behaviors demo [CHAR LIMIT=NONE]">Slider label behavior demo</string>
2324
<string name="cat_slider_demo_corner_title" description="Title for the Slider with different corner sizes demo [CHAR LIMIT=NONE]">Slider custom corners demo</string>

docs/components/Slider.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ slider also has tick marks.
295295
| Element | Attribute | Related method(s) | Default value |
296296
|--------------------------------------------|------------------------------|-------------------------------------------------------------|--------------------------------------|
297297
| **Orientation** | `android:orientation` | `setOrientation`<br/>`isVertical` | `horizontal` |
298+
| **Centered** | `android:centered` | `setCentered`<br/>`isCentered` | `false` |
298299
| **Min value** | `android:valueFrom` | `setValueFrom`<br/>`getValueFrom` | N/A |
299300
| **Max value** | `android:valueTo` | `setValueTo`<br/>`getValueTo` | N/A |
300301
| **Step size (discrete)** | `android:stepSize` | `setStepSize`<br/>`getStepSize` | N/A |

lib/java/com/google/android/material/slider/BaseSlider.java

Lines changed: 108 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ abstract class BaseSlider<
343343
private int trackStopIndicatorSize;
344344
private int trackCornerSize;
345345
private int trackInsideCornerSize;
346+
private boolean centered = false;
346347
@Nullable private Drawable trackIconActiveStart;
347348
private boolean trackIconActiveStartMutated = false;
348349
@Nullable private Drawable trackIconActiveEnd;
@@ -542,6 +543,7 @@ private void processAttributes(Context context, AttributeSet attrs, int defStyle
542543
valueFrom = a.getFloat(R.styleable.Slider_android_valueFrom, 0.0f);
543544
valueTo = a.getFloat(R.styleable.Slider_android_valueTo, 1.0f);
544545
setValues(valueFrom);
546+
setCentered(a.getBoolean(R.styleable.Slider_centered, false));
545547
stepSize = a.getFloat(R.styleable.Slider_android_stepSize, 0.0f);
546548

547549
float defaultMinTouchTargetSize =
@@ -2396,6 +2398,30 @@ public void setOrientation(@Orientation int orientation) {
23962398
updateWidgetLayout(true);
23972399
}
23982400

2401+
/**
2402+
* Sets the slider to be in centered configuration, meaning the starting value is positioned in
2403+
* the middle of the slider.
2404+
*
2405+
* @param isCentered boolean to use for the slider's centered configuration.
2406+
* @attr ref com.google.android.material.R.styleable#Slider_centered
2407+
* @see #isCentered()
2408+
*/
2409+
public void setCentered(boolean isCentered) {
2410+
if (this.centered == isCentered) {
2411+
return;
2412+
}
2413+
this.centered = isCentered;
2414+
2415+
// if centered, the default value is at the center
2416+
if (isCentered) {
2417+
setValues((valueFrom + valueTo) / 2f);
2418+
} else {
2419+
setValues(valueFrom);
2420+
}
2421+
2422+
updateWidgetLayout(true);
2423+
}
2424+
23992425
@Override
24002426
protected void onAttachedToWindow() {
24012427
super.onAttachedToWindow();
@@ -2562,7 +2588,9 @@ protected void onDraw(@NonNull Canvas canvas) {
25622588
int yCenter = calculateTrackCenter();
25632589

25642590
drawInactiveTracks(canvas, trackWidth, yCenter);
2565-
drawActiveTracks(canvas, trackWidth, yCenter);
2591+
if (!isCentered()) {
2592+
drawActiveTracks(canvas, trackWidth, yCenter);
2593+
}
25662594

25672595
if (isRtl() || isVertical()) {
25682596
drawTrackIcons(canvas, activeTrackRect, inactiveTrackLeftRect);
@@ -2592,55 +2620,53 @@ private float[] getActiveRange() {
25922620
float left = normalizeValue(values.size() == 1 ? valueFrom : min);
25932621
float right = normalizeValue(max);
25942622

2623+
// When centered, there is no active range, left == right in order to draw the inactive track on
2624+
// both sides of the thumb leaving space for it, covering the entirety of the track.
2625+
if (isCentered()) {
2626+
left = right;
2627+
}
2628+
25952629
// In RTL we draw things in reverse, so swap the left and right range values
25962630
return isRtl() || isVertical() ? new float[] {right, left} : new float[] {left, right};
25972631
}
25982632

25992633
private void drawInactiveTracks(@NonNull Canvas canvas, int width, int yCenter) {
2600-
populateInactiveTrackRightRect(width, yCenter);
2601-
updateTrack(
2602-
canvas,
2603-
inactiveTrackPaint,
2604-
inactiveTrackRightRect,
2605-
getTrackCornerSize(),
2606-
FullCornerDirection.RIGHT);
2607-
2608-
// Also draw inactive track to the left if there is any
2609-
populateInactiveTrackLeftRect(width, yCenter);
2610-
updateTrack(
2634+
float[] activeRange = getActiveRange();
2635+
float top = yCenter - trackThickness / 2f;
2636+
float bottom = yCenter + trackThickness / 2f;
2637+
2638+
drawInactiveTrackSection(
2639+
trackSidePadding - getTrackCornerSize(),
2640+
trackSidePadding + activeRange[0] * width - thumbTrackGapSize,
2641+
top,
2642+
bottom,
26112643
canvas,
2612-
inactiveTrackPaint,
26132644
inactiveTrackLeftRect,
2614-
getTrackCornerSize(),
26152645
FullCornerDirection.LEFT);
2646+
drawInactiveTrackSection(
2647+
trackSidePadding + activeRange[1] * width + thumbTrackGapSize,
2648+
trackSidePadding + width + getTrackCornerSize(),
2649+
top,
2650+
bottom,
2651+
canvas,
2652+
inactiveTrackRightRect,
2653+
FullCornerDirection.RIGHT);
26162654
}
26172655

2618-
private void populateInactiveTrackRightRect(int width, int yCenter) {
2619-
float[] activeRange = getActiveRange();
2620-
float right = trackSidePadding + activeRange[1] * width;
2621-
if (right < trackSidePadding + width) {
2622-
inactiveTrackRightRect.set(
2623-
right + thumbTrackGapSize,
2624-
yCenter - trackThickness / 2f,
2625-
trackSidePadding + width + getTrackCornerSize(),
2626-
yCenter + trackThickness / 2f);
2627-
} else {
2628-
inactiveTrackRightRect.setEmpty();
2629-
}
2630-
}
2631-
2632-
private void populateInactiveTrackLeftRect(int width, int yCenter) {
2633-
float[] activeRange = getActiveRange();
2634-
float left = trackSidePadding + activeRange[0] * width;
2635-
if (left > trackSidePadding) {
2636-
inactiveTrackLeftRect.set(
2637-
trackSidePadding - getTrackCornerSize(),
2638-
yCenter - trackThickness / 2f,
2639-
left - thumbTrackGapSize,
2640-
yCenter + trackThickness / 2f);
2656+
private void drawInactiveTrackSection(
2657+
float from,
2658+
float to,
2659+
float top,
2660+
float bottom,
2661+
@NonNull Canvas canvas,
2662+
RectF rect,
2663+
FullCornerDirection direction) {
2664+
if (to - from > getTrackCornerSize() - thumbTrackGapSize) {
2665+
rect.set(from, top, to, bottom);
26412666
} else {
2642-
inactiveTrackLeftRect.setEmpty();
2667+
rect.setEmpty();
26432668
}
2669+
updateTrack(canvas, inactiveTrackPaint, rect, getTrackCornerSize(), direction);
26442670
}
26452671

26462672
/**
@@ -2923,26 +2949,41 @@ private void maybeDrawTicks(@NonNull Canvas canvas) {
29232949

29242950
// Draw ticks on the left inactive track (if any).
29252951
if (leftActiveTickIndex > 0) {
2926-
canvas.drawPoints(ticksCoordinates, 0, leftActiveTickIndex * 2, inactiveTicksPaint);
2952+
drawTicks(0, leftActiveTickIndex * 2, canvas, inactiveTicksPaint);
29272953
}
29282954

29292955
// Draw ticks on the active track (if any).
29302956
if (leftActiveTickIndex <= rightActiveTickIndex) {
2931-
canvas.drawPoints(
2932-
ticksCoordinates,
2957+
drawTicks(
29332958
leftActiveTickIndex * 2,
2934-
(rightActiveTickIndex - leftActiveTickIndex + 1) * 2,
2935-
activeTicksPaint);
2959+
(rightActiveTickIndex + 1) * 2,
2960+
canvas,
2961+
isCentered() ? inactiveTicksPaint : activeTicksPaint); // centered uses inactive color.
29362962
}
29372963

29382964
// Draw ticks on the right inactive track (if any).
29392965
if ((rightActiveTickIndex + 1) * 2 < ticksCoordinates.length) {
2940-
canvas.drawPoints(
2941-
ticksCoordinates,
2942-
(rightActiveTickIndex + 1) * 2,
2943-
ticksCoordinates.length - (rightActiveTickIndex + 1) * 2,
2944-
inactiveTicksPaint);
2966+
drawTicks(
2967+
(rightActiveTickIndex + 1) * 2, ticksCoordinates.length, canvas, inactiveTicksPaint);
2968+
}
2969+
}
2970+
2971+
private void drawTicks(int from, int to, Canvas canvas, Paint paint) {
2972+
for (int i = from; i < to; i += 2) {
2973+
if (isOverlappingThumb(ticksCoordinates[i])) {
2974+
continue;
2975+
}
2976+
canvas.drawPoint(ticksCoordinates[i], ticksCoordinates[i + 1], paint);
2977+
}
2978+
}
2979+
2980+
private boolean isOverlappingThumb(float tickCoordinate) {
2981+
float threshold = thumbTrackGapSize + thumbWidth / 2f;
2982+
for (float value : values) {
2983+
float valueToX = valueToX(value);
2984+
return tickCoordinate >= valueToX - threshold && tickCoordinate <= valueToX + threshold;
29452985
}
2986+
return false;
29462987
}
29472988

29482989
private void maybeDrawStopIndicator(@NonNull Canvas canvas, int yCenter) {
@@ -2954,13 +2995,25 @@ private void maybeDrawStopIndicator(@NonNull Canvas canvas, int yCenter) {
29542995
if (values.get(values.size() - 1) < valueTo) {
29552996
drawStopIndicator(canvas, valueToX(valueTo), yCenter);
29562997
}
2957-
// Multiple thumbs, inactive track may be visible at the start.
2958-
if (values.size() > 1 && values.get(0) > valueFrom) {
2998+
// Centered, multiple thumbs, inactive track may be visible at the start.
2999+
if (isCentered() || (values.size() > 1 && values.get(0) > valueFrom)) {
29593000
drawStopIndicator(canvas, valueToX(valueFrom), yCenter);
29603001
}
3002+
// Centered, draw indicator in the middle of the track.
3003+
if (isCentered()) {
3004+
drawStopIndicator(canvas, (valueToX(valueTo) + valueToX(valueFrom)) / 2f, yCenter);
3005+
}
29613006
}
29623007

29633008
private void drawStopIndicator(@NonNull Canvas canvas, float x, float y) {
3009+
// Prevent drawing indicator on the thumbs.
3010+
for (float value : values) {
3011+
float valueToX = valueToX(value);
3012+
float threshold = thumbTrackGapSize + thumbWidth / 2f;
3013+
if (x >= valueToX - threshold && x <= valueToX + threshold) {
3014+
return;
3015+
}
3016+
}
29643017
if (isVertical()) {
29653018
canvas.drawPoint(y, x, stopIndicatorPaint);
29663019
} else {
@@ -3751,10 +3804,14 @@ final boolean isRtl() {
37513804
return getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
37523805
}
37533806

3754-
final boolean isVertical() {
3807+
public boolean isVertical() {
37553808
return widgetOrientation == VERTICAL;
37563809
}
37573810

3811+
public boolean isCentered() {
3812+
return centered;
3813+
}
3814+
37583815
/**
37593816
* Attempts to move focus to next or previous thumb <i>independent of layout direction</i> and
37603817
* returns whether the focused thumb changed. If focused thumb didn't change, we're at the view

lib/java/com/google/android/material/slider/res-public/values/public.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<resources>
1818
<public name="sliderStyle" type="attr"/>
1919

20+
<public name="centered" type="attr" />
2021
<public name="haloColor" type="attr" />
2122
<public name="haloRadius" type="attr" />
2223
<public name="labelBehavior" type="attr" />

lib/java/com/google/android/material/slider/res/values/attrs.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<!-- Whether the Slider is enabled. -->
2424
<attr name="android:enabled" />
2525
<attr name="android:orientation" />
26+
<attr name="centered" format="boolean" />
2627
<!-- The color of the slider's halo. -->
2728
<attr name="haloColor" format="color" />
2829
<!-- The radius of the halo. -->

0 commit comments

Comments
 (0)