/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.material.slider; import com.google.android.material.R; import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT; import static androidx.core.math.MathUtils.clamp; import static com.google.android.material.slider.LabelFormatter.LABEL_FLOATING; import static com.google.android.material.slider.LabelFormatter.LABEL_GONE; import static com.google.android.material.slider.LabelFormatter.LABEL_WITHIN_BOUNDS; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import static java.lang.Float.compare; import static java.lang.Math.abs; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Paint.Cap; import android.graphics.Paint.Style; import android.graphics.PorterDuff.Mode; import android.graphics.PorterDuffXfermode; import android.graphics.Rect; import android.graphics.Region.Op; import android.graphics.drawable.Drawable; import android.graphics.drawable.RippleDrawable; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat; import androidx.appcompat.content.res.AppCompatResources; import android.util.AttributeSet; import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewParent; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.widget.SeekBar; import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.Dimension; import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.customview.widget.ExploreByTouchHelper; import com.google.android.material.drawable.DrawableUtils; import com.google.android.material.internal.DescendantOffsetUtils; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewOverlayImpl; import com.google.android.material.internal.ViewUtils; import com.google.android.material.resources.MaterialResources; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.tooltip.TooltipDrawable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.math.BigDecimal; import java.math.MathContext; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; /** * The slider can function either as a continuous slider, or as a discrete slider. The mode of * operation is controlled by the value of the step size. If the step size is set to 0, the slider * operates as a continuous slider where the slider's thumb can be moved to any position along the * horizontal line. If the step size is set to a number greater than 0, the slider operates as a * discrete slider where the slider's thumb will snap to the closest valid value. See {@link * #setStepSize(float)}. * *

The {@link LabelFormatter} interface defines a formatter to be used to render text within the * value indicator label on interaction. * *

{@link BasicLabelFormatter} is a simple implementation of the {@link LabelFormatter} that * displays the selected value using letters to indicate magnitude (e.g.: 1.5K, 3M, 12B, etc..). * *

With the default style {@link * com.google.android.material.R.style#Widget_MaterialComponents_Slider}, colorPrimary and * colorOnPrimary are used to customize the color of the slider when enabled, and colorOnSurface is * used when disabled. The following attributes are used to customize the slider's appearance * further: * *

* *

The following XML attributes are used to set the slider's various parameters of operation: * *

* * @attr ref com.google.android.material.R.styleable#Slider_android_enabled * @attr ref com.google.android.material.R.styleable#Slider_android_stepSize * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom * @attr ref com.google.android.material.R.styleable#Slider_android_valueTo * @attr ref com.google.android.material.R.styleable#Slider_haloColor * @attr ref com.google.android.material.R.styleable#Slider_haloRadius * @attr ref com.google.android.material.R.styleable#Slider_labelBehavior * @attr ref com.google.android.material.R.styleable#Slider_labelStyle * @attr ref com.google.android.material.R.styleable#Slider_thumbColor * @attr ref com.google.android.material.R.styleable#Slider_thumbElevation * @attr ref com.google.android.material.R.styleable#Slider_thumbRadius * @attr ref com.google.android.material.R.styleable#Slider_tickColor * @attr ref com.google.android.material.R.styleable#Slider_tickColorActive * @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive * @attr ref com.google.android.material.R.styleable#Slider_tickVisible * @attr ref com.google.android.material.R.styleable#Slider_trackColor * @attr ref com.google.android.material.R.styleable#Slider_trackColorActive * @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive * @attr ref com.google.android.material.R.styleable#Slider_trackHeight */ abstract class BaseSlider< S extends BaseSlider, L extends BaseOnChangeListener, T extends BaseOnSliderTouchListener> extends View { private static final String TAG = BaseSlider.class.getSimpleName(); private static final String EXCEPTION_ILLEGAL_VALUE = "Slider value(%s) must be greater or equal to valueFrom(%s), and lower or equal to" + " valueTo(%s)"; private static final String EXCEPTION_ILLEGAL_DISCRETE_VALUE = "Value(%s) must be equal to valueFrom(%s) plus a multiple of stepSize(%s) when using" + " stepSize(%s)"; private static final String EXCEPTION_ILLEGAL_VALUE_FROM = "valueFrom(%s) must be smaller than valueTo(%s)"; private static final String EXCEPTION_ILLEGAL_VALUE_TO = "valueTo(%s) must be greater than valueFrom(%s)"; private static final String EXCEPTION_ILLEGAL_STEP_SIZE = "The stepSize(%s) must be 0, or a factor of the valueFrom(%s)-valueTo(%s) range"; private static final String WARNING_FLOATING_POINT_ERRROR = "Floating point value used for %s(%s). Using floats can have rounding errors which may" + " result in incorrect values. Instead, consider using integers with a custom" + " LabelFormatter to display the value correctly."; private static final int TIMEOUT_SEND_ACCESSIBILITY_EVENT = 200; private static final int HALO_ALPHA = 63; private static final double THRESHOLD = .0001; static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_Slider; static final int UNIT_VALUE = 1; static final int UNIT_PX = 0; @NonNull private final Paint inactiveTrackPaint; @NonNull private final Paint activeTrackPaint; @NonNull private final Paint thumbPaint; @NonNull private final Paint haloPaint; @NonNull private final Paint inactiveTicksPaint; @NonNull private final Paint activeTicksPaint; @NonNull private final AccessibilityHelper accessibilityHelper; private final AccessibilityManager accessibilityManager; private AccessibilityEventSender accessibilityEventSender; private interface TooltipDrawableFactory { TooltipDrawable createTooltipDrawable(); } @NonNull private final TooltipDrawableFactory labelMaker; @NonNull private final List labels = new ArrayList<>(); @NonNull private final List changeListeners = new ArrayList<>(); @NonNull private final List touchListeners = new ArrayList<>(); private final int scaledTouchSlop; private int widgetHeight; private int labelBehavior; private int trackHeight; private int trackSidePadding; private int trackTop; private int thumbRadius; private int haloRadius; private int labelPadding; private float touchDownX; private MotionEvent lastEvent; private LabelFormatter formatter; private boolean thumbIsPressed = false; private float valueFrom; private float valueTo; // Holds the values set to this slider. We keep this array sorted in order to check if the value // has been changed when a new value is set and to find the minimum and maximum values. private ArrayList values = new ArrayList<>(); // The index of the currently touched thumb. private int activeThumbIdx = -1; // The index of the currently focused thumb. private int focusedThumbIdx = -1; private float stepSize = 0.0f; private float[] ticksCoordinates; private boolean tickVisible = true; private int trackWidth; private boolean forceDrawCompatHalo; private boolean isLongPress = false; private boolean dirtyConfig; @NonNull private ColorStateList haloColor; @NonNull private ColorStateList tickColorActive; @NonNull private ColorStateList tickColorInactive; @NonNull private ColorStateList trackColorActive; @NonNull private ColorStateList trackColorInactive; @NonNull private final MaterialShapeDrawable thumbDrawable = new MaterialShapeDrawable(); private float touchPosition; @SeparationUnit private int separationUnit = UNIT_PX; /** * Determines the behavior of the label which can be any of the following. * *
    *
  • {@code LABEL_FLOATING}: The label will only be visible on interaction. It will float * above the slider and may cover views above this one. This is the default and recommended * behavior. *
  • {@code LABEL_WITHIN_BOUNDS}: The label will only be visible on interaction. The label * will always be drawn within the bounds of this view. This means extra space will be * visible above the slider when the label is not visible. *
  • {@code LABEL_GONE}: The label will never be drawn. *
*/ @IntDef({LABEL_FLOATING, LABEL_WITHIN_BOUNDS, LABEL_GONE}) @Retention(RetentionPolicy.SOURCE) @interface LabelBehavior {} @IntDef({UNIT_PX, UNIT_VALUE}) @Retention(RetentionPolicy.SOURCE) @interface SeparationUnit {} public BaseSlider(@NonNull Context context) { this(context, null); } public BaseSlider(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, R.attr.sliderStyle); } public BaseSlider( @NonNull Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr); // Ensure we are using the correctly themed context rather than the context that was passed in. context = getContext(); inactiveTrackPaint = new Paint(); inactiveTrackPaint.setStyle(Style.STROKE); inactiveTrackPaint.setStrokeCap(Cap.ROUND); activeTrackPaint = new Paint(); activeTrackPaint.setStyle(Style.STROKE); activeTrackPaint.setStrokeCap(Cap.ROUND); thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG); thumbPaint.setStyle(Style.FILL); thumbPaint.setXfermode(new PorterDuffXfermode(Mode.CLEAR)); haloPaint = new Paint(Paint.ANTI_ALIAS_FLAG); haloPaint.setStyle(Style.FILL); inactiveTicksPaint = new Paint(); inactiveTicksPaint.setStyle(Style.STROKE); inactiveTicksPaint.setStrokeCap(Cap.ROUND); activeTicksPaint = new Paint(); activeTicksPaint.setStyle(Style.STROKE); activeTicksPaint.setStrokeCap(Cap.ROUND); loadResources(context.getResources()); // Because there's currently no way to copy the TooltipDrawable we use this to make more if more // thumbs are added. labelMaker = new TooltipDrawableFactory() { @Override public TooltipDrawable createTooltipDrawable() { final TypedArray a = ThemeEnforcement.obtainStyledAttributes( getContext(), attrs, R.styleable.Slider, defStyleAttr, DEF_STYLE_RES); TooltipDrawable d = parseLabelDrawable(getContext(), a); a.recycle(); return d; } }; processAttributes(context, attrs, defStyleAttr); setFocusable(true); setClickable(true); // Set up the thumb drawable to always show the compat shadow. thumbDrawable.setShadowCompatibilityMode(MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS); scaledTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); accessibilityHelper = new AccessibilityHelper(this); ViewCompat.setAccessibilityDelegate(this, accessibilityHelper); accessibilityManager = (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); } private void loadResources(@NonNull Resources resources) { widgetHeight = resources.getDimensionPixelSize(R.dimen.mtrl_slider_widget_height); trackSidePadding = resources.getDimensionPixelOffset(R.dimen.mtrl_slider_track_side_padding); trackTop = resources.getDimensionPixelOffset(R.dimen.mtrl_slider_track_top); labelPadding = resources.getDimensionPixelSize(R.dimen.mtrl_slider_label_padding); } private void processAttributes(Context context, AttributeSet attrs, int defStyleAttr) { TypedArray a = ThemeEnforcement.obtainStyledAttributes( context, attrs, R.styleable.Slider, defStyleAttr, DEF_STYLE_RES); valueFrom = a.getFloat(R.styleable.Slider_android_valueFrom, 0.0f); valueTo = a.getFloat(R.styleable.Slider_android_valueTo, 1.0f); setValues(valueFrom); stepSize = a.getFloat(R.styleable.Slider_android_stepSize, 0.0f); boolean hasTrackColor = a.hasValue(R.styleable.Slider_trackColor); int trackColorInactiveRes = hasTrackColor ? R.styleable.Slider_trackColor : R.styleable.Slider_trackColorInactive; int trackColorActiveRes = hasTrackColor ? R.styleable.Slider_trackColor : R.styleable.Slider_trackColorActive; ColorStateList trackColorInactive = MaterialResources.getColorStateList(context, a, trackColorInactiveRes); setTrackInactiveTintList( trackColorInactive != null ? trackColorInactive : AppCompatResources.getColorStateList( context, R.color.material_slider_inactive_track_color)); ColorStateList trackColorActive = MaterialResources.getColorStateList(context, a, trackColorActiveRes); setTrackActiveTintList( trackColorActive != null ? trackColorActive : AppCompatResources.getColorStateList( context, R.color.material_slider_active_track_color)); ColorStateList thumbColor = MaterialResources.getColorStateList(context, a, R.styleable.Slider_thumbColor); thumbDrawable.setFillColor(thumbColor); if (a.hasValue(R.styleable.Slider_thumbStrokeColor)) { setThumbStrokeColor( MaterialResources.getColorStateList(context, a, R.styleable.Slider_thumbStrokeColor)); } setThumbStrokeWidth(a.getDimension(R.styleable.Slider_thumbStrokeWidth, 0)); ColorStateList haloColor = MaterialResources.getColorStateList(context, a, R.styleable.Slider_haloColor); setHaloTintList( haloColor != null ? haloColor : AppCompatResources.getColorStateList(context, R.color.material_slider_halo_color)); tickVisible = a.getBoolean(R.styleable.Slider_tickVisible, true); boolean hasTickColor = a.hasValue(R.styleable.Slider_tickColor); int tickColorInactiveRes = hasTickColor ? R.styleable.Slider_tickColor : R.styleable.Slider_tickColorInactive; int tickColorActiveRes = hasTickColor ? R.styleable.Slider_tickColor : R.styleable.Slider_tickColorActive; ColorStateList tickColorInactive = MaterialResources.getColorStateList(context, a, tickColorInactiveRes); setTickInactiveTintList( tickColorInactive != null ? tickColorInactive : AppCompatResources.getColorStateList( context, R.color.material_slider_inactive_tick_marks_color)); ColorStateList tickColorActive = MaterialResources.getColorStateList(context, a, tickColorActiveRes); setTickActiveTintList( tickColorActive != null ? tickColorActive : AppCompatResources.getColorStateList( context, R.color.material_slider_active_tick_marks_color)); setThumbRadius(a.getDimensionPixelSize(R.styleable.Slider_thumbRadius, 0)); setHaloRadius(a.getDimensionPixelSize(R.styleable.Slider_haloRadius, 0)); setThumbElevation(a.getDimension(R.styleable.Slider_thumbElevation, 0)); setTrackHeight(a.getDimensionPixelSize(R.styleable.Slider_trackHeight, 0)); labelBehavior = a.getInt(R.styleable.Slider_labelBehavior, LABEL_FLOATING); if (!a.getBoolean(R.styleable.Slider_android_enabled, true)) { setEnabled(false); } a.recycle(); } @NonNull private static TooltipDrawable parseLabelDrawable( @NonNull Context context, @NonNull TypedArray a) { return TooltipDrawable.createFromAttributes( context, null, 0, a.getResourceId(R.styleable.Slider_labelStyle, R.style.Widget_MaterialComponents_Tooltip)); } private void validateValueFrom() { if (valueFrom >= valueTo) { throw new IllegalStateException( String.format( EXCEPTION_ILLEGAL_VALUE_FROM, Float.toString(valueFrom), Float.toString(valueTo))); } } private void validateValueTo() { if (valueTo <= valueFrom) { throw new IllegalStateException( String.format( EXCEPTION_ILLEGAL_VALUE_TO, Float.toString(valueTo), Float.toString(valueFrom))); } } private boolean valueLandsOnTick(float value) { // Check that the value is a multiple of stepSize given the offset of valueFrom // We're using BigDecimal here to avoid floating point rounding errors. double potentialTickValue = new BigDecimal(Float.toString(value)) .subtract(new BigDecimal(Float.toString(valueFrom))) .divide(new BigDecimal(Float.toString(stepSize)), MathContext.DECIMAL64) .doubleValue(); // If the potentialTickValue is a whole number, it means the value lands on a tick. return Math.abs(Math.round(potentialTickValue) - potentialTickValue) < THRESHOLD; } private void validateStepSize() { if (stepSize > 0.0f && !valueLandsOnTick(valueTo)) { throw new IllegalStateException( String.format( EXCEPTION_ILLEGAL_STEP_SIZE, Float.toString(stepSize), Float.toString(valueFrom), Float.toString(valueTo))); } } private void validateValues() { for (Float value : values) { if (value < valueFrom || value > valueTo) { throw new IllegalStateException( String.format( EXCEPTION_ILLEGAL_VALUE, Float.toString(value), Float.toString(valueFrom), Float.toString(valueTo))); } if (stepSize > 0.0f && !valueLandsOnTick(value)) { throw new IllegalStateException( String.format( EXCEPTION_ILLEGAL_DISCRETE_VALUE, Float.toString(value), Float.toString(valueFrom), Float.toString(stepSize), Float.toString(stepSize))); } } } private void warnAboutFloatingPointError() { if (stepSize == 0) { // Only warn if slider uses a step value. return; } if ((int) stepSize != stepSize) { Log.w(TAG, String.format(WARNING_FLOATING_POINT_ERRROR, "stepSize", stepSize)); } if ((int) valueFrom != valueFrom) { Log.w(TAG, String.format(WARNING_FLOATING_POINT_ERRROR, "valueFrom", valueFrom)); } if ((int) valueTo != valueTo) { Log.w(TAG, String.format(WARNING_FLOATING_POINT_ERRROR, "valueTo", valueTo)); } } private void validateConfigurationIfDirty() { if (dirtyConfig) { validateValueFrom(); validateValueTo(); validateStepSize(); validateValues(); warnAboutFloatingPointError(); dirtyConfig = false; } } /** * Returns the slider's {@code valueFrom} value. * * @see #setValueFrom(float) * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom */ public float getValueFrom() { return valueFrom; } /** * Sets the slider's {@code valueFrom} value. * *

The {@code valueFrom} value must be strictly lower than the {@code valueTo} value. If that * is not the case, an {@link IllegalStateException} will be thrown when the view is laid out. * * @param valueFrom The minimum value for the slider's range of values * @see #getValueFrom() * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom */ public void setValueFrom(float valueFrom) { this.valueFrom = valueFrom; dirtyConfig = true; postInvalidate(); } /** * Returns the slider's {@code valueTo} value. * * @see #setValueTo(float) * @attr ref com.google.android.material.R.styleable#Slider_android_valueTo */ public float getValueTo() { return valueTo; } /** * Sets the slider's {@code valueTo} value. * *

The {@code valueTo} value must be strictly greater than the {@code valueFrom} value. If that * is not the case, an {@link IllegalStateException} will be thrown when the view is laid out. * * @param valueTo The maximum value for the slider's range of values * @see #getValueTo() * @attr ref com.google.android.material.R.styleable#Slider_android_valueTo */ public void setValueTo(float valueTo) { this.valueTo = valueTo; dirtyConfig = true; postInvalidate(); } @NonNull List getValues() { return new ArrayList<>(values); } /** * Sets multiple values for the slider. Each value will represent a different thumb. * *

Each value must be greater or equal to {@code valueFrom}, and lesser or equal to {@code * valueTo}. If that is not the case, an {@link IllegalStateException} will be thrown when the * view is laid out. * *

If the slider is in discrete mode (i.e. the tick increment value is greater than 0), the * values must be set to a value falls on a tick (i.e.: {@code value == valueFrom + x * stepSize}, * where {@code x} is an integer equal to or greater than 0). If that is not the case, an {@link * IllegalStateException} will be thrown when the view is laid out. * * @param values An array of values to set. * @see #getValues() */ void setValues(@NonNull Float... values) { ArrayList list = new ArrayList<>(); Collections.addAll(list, values); setValuesInternal(list); } /** * Sets multiple values for the slider. Each value will represent a different thumb. * *

Each value must be greater or equal to {@code valueFrom}, and lesser or equal to {@code * valueTo}. If that is not the case, an {@link IllegalStateException} will be thrown when the * view is laid out. * *

If the slider is in discrete mode (i.e. the tick increment value is greater than 0), the * values must be set to a value falls on a tick (i.e.: {@code value == valueFrom + x * stepSize}, * where {@code x} is an integer equal to or greater than 0). If that is not the case, an {@link * IllegalStateException} will be thrown when the view is laid out. * * @param values An array of values to set. * @throws IllegalArgumentException If {@code values} is empty. */ void setValues(@NonNull List values) { setValuesInternal(new ArrayList<>(values)); } /** * This method assumes the list passed in is a copy. It is split out so we can call it from {@link * #setValues(Float...)} and {@link #setValues(List)} */ private void setValuesInternal(@NonNull ArrayList values) { if (values.isEmpty()) { throw new IllegalArgumentException("At least one value must be set"); } Collections.sort(values); if (this.values.size() == values.size()) { if (this.values.equals(values)) { return; } } this.values = values; dirtyConfig = true; // Only update the focused thumb index. The active thumb index will be updated on touch. focusedThumbIdx = 0; updateHaloHotspot(); createLabelPool(); dispatchOnChangedProgramatically(); postInvalidate(); } private void createLabelPool() { // If there are too many labels, remove the extra ones from the end. if (labels.size() > values.size()) { List tooltipDrawables = labels.subList(values.size(), labels.size()); for (TooltipDrawable label : tooltipDrawables) { if (ViewCompat.isAttachedToWindow(this)) { detachLabelFromContentView(label); } } tooltipDrawables.clear(); } // If there's not enough labels, add more. while (labels.size() < values.size()) { TooltipDrawable tooltipDrawable = labelMaker.createTooltipDrawable(); labels.add(tooltipDrawable); if (ViewCompat.isAttachedToWindow(this)) { attachLabelToContentView(tooltipDrawable); } } // Add a stroke if there is more than one label for when they overlap. int strokeWidth = labels.size() == 1 ? 0 : 1; for (TooltipDrawable label : labels) { label.setStrokeWidth(strokeWidth); } } /** * Returns the step size used to mark the ticks. * *

A step size of 0 means that the slider is operating in continuous mode. A step size greater * than 0 means that the slider is operating in discrete mode. * * @see #setStepSize(float) * @attr ref com.google.android.material.R.styleable#Slider_android_stepSize */ public float getStepSize() { return stepSize; } /** * Sets the step size to use to mark the ticks. * *

Setting this value to 0 will make the slider operate in continuous mode. Setting this value * to a number greater than 0 will make the slider operate in discrete mode. * *

The step size must evenly divide the range described by the {@code valueFrom} and {@code * valueTo}, it must be a factor of the range. If the step size is not a factor of the range, an * {@link IllegalStateException} will be thrown when this view is laid out. * *

Setting this value to a negative value will result in an {@link IllegalArgumentException}. * * @param stepSize The interval value at which ticks must be drawn. Set to 0 to operate the slider * in continuous mode and not have any ticks. * @throws IllegalArgumentException If the step size is less than 0 * @see #getStepSize() * @attr ref com.google.android.material.R.styleable#Slider_android_stepSize */ public void setStepSize(float stepSize) { if (stepSize < 0.0f) { throw new IllegalArgumentException( String.format( EXCEPTION_ILLEGAL_STEP_SIZE, Float.toString(stepSize), Float.toString(valueFrom), Float.toString(valueTo))); } if (this.stepSize != stepSize) { this.stepSize = stepSize; dirtyConfig = true; postInvalidate(); } } /** Returns the index of the currently focused thumb */ public int getFocusedThumbIndex() { return focusedThumbIdx; } /** Sets the index of the currently focused thumb */ public void setFocusedThumbIndex(int index) { if (index < 0 || index >= values.size()) { throw new IllegalArgumentException("index out of range"); } focusedThumbIdx = index; accessibilityHelper.requestKeyboardFocusForVirtualView(focusedThumbIdx); postInvalidate(); } protected void setActiveThumbIndex(int index) { activeThumbIdx = index; } /** Returns the index of the currently active thumb, or -1 if no thumb is active */ public int getActiveThumbIndex() { return activeThumbIdx; } /** * Registers a callback to be invoked when the slider changes. * * @param listener The callback to run when the slider changes */ public void addOnChangeListener(@Nullable L listener) { changeListeners.add(listener); } /** * Removes a callback for value changes from this slider. * * @param listener The callback that'll stop receive slider changes */ public void removeOnChangeListener(@NonNull L listener) { changeListeners.remove(listener); } /** Removes all instances of attached to this slider */ public void clearOnChangeListeners() { changeListeners.clear(); } /** * Registers a callback to be invoked when the slider touch event is being started or stopped * * @param listener The callback to run when the slider starts or stops being touched */ public void addOnSliderTouchListener(@NonNull T listener) { touchListeners.add(listener); } /** * Removes a callback to be invoked when the slider touch event is being started or stopped * * @param listener The callback that'll stop be notified when the slider is being touched */ public void removeOnSliderTouchListener(@NonNull T listener) { touchListeners.remove(listener); } /** Removes all instances of touch listeners attached to this slider */ public void clearOnSliderTouchListeners() { touchListeners.clear(); } /** * Returns {@code true} if the slider has a {@link LabelFormatter} attached, {@code false} * otherwise. */ public boolean hasLabelFormatter() { return formatter != null; } /** * Registers a {@link LabelFormatter} to be used to format the value displayed in the bubble shown * when the slider operates in discrete mode. * * @param formatter The {@link LabelFormatter} to use to format the bubble's text */ public void setLabelFormatter(@Nullable LabelFormatter formatter) { this.formatter = formatter; } /** * Returns the elevation of the thumb. * * @see #setThumbElevation(float) * @see #setThumbElevationResource(int) * @attr ref com.google.android.material.R.styleable#Slider_thumbElevation */ public float getThumbElevation() { return thumbDrawable.getElevation(); } /** * Sets the elevation of the thumb. * * @see #getThumbElevation() * @attr ref com.google.android.material.R.styleable#Slider_thumbElevation */ public void setThumbElevation(float elevation) { thumbDrawable.setElevation(elevation); } /** * Sets the elevation of the thumb from a dimension resource. * * @see #getThumbElevation() * @attr ref com.google.android.material.R.styleable#Slider_thumbElevation */ public void setThumbElevationResource(@DimenRes int elevation) { setThumbElevation(getResources().getDimension(elevation)); } /** * Returns the radius of the thumb. * * @see #setThumbRadius(int) * @see #setThumbRadiusResource(int) * @attr ref com.google.android.material.R.styleable#Slider_thumbRadius */ @Dimension public int getThumbRadius() { return thumbRadius; } /** * Sets the radius of the thumb in pixels. * * @see #getThumbRadius() * @attr ref com.google.android.material.R.styleable#Slider_thumbRadius */ public void setThumbRadius(@IntRange(from = 0) @Dimension int radius) { if (radius == thumbRadius) { return; } thumbRadius = radius; thumbDrawable.setShapeAppearanceModel( ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, thumbRadius).build()); thumbDrawable.setBounds(0, 0, thumbRadius * 2, thumbRadius * 2); postInvalidate(); } /** * Sets the radius of the thumb from a dimension resource. * * @see #getThumbRadius() * @attr ref com.google.android.material.R.styleable#Slider_thumbRadius */ public void setThumbRadiusResource(@DimenRes int radius) { setThumbRadius(getResources().getDimensionPixelSize(radius)); } /** * Sets the stroke color for the thumbs. Both thumbStroke color and thumbStroke width must be set * for a stroke to be drawn. * * @param thumbStrokeColor Color to use for the stroke in the thumbs. * @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeColor * @see #setThumbStrokeColorResource(int) * @see #getThumbStrokeColor() */ public void setThumbStrokeColor(@Nullable ColorStateList thumbStrokeColor) { thumbDrawable.setStrokeColor(thumbStrokeColor); postInvalidate(); } /** * Sets the stroke color resource for the thumbs. Both thumbStroke color and thumbStroke width * must be set for a stroke to be drawn. * * @param thumbStrokeColorResourceId Color resource to use for the stroke. * @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeColor * @see #setThumbStrokeColor(ColorStateList) * @see #getThumbStrokeColor() */ public void setThumbStrokeColorResource(@ColorRes int thumbStrokeColorResourceId) { if (thumbStrokeColorResourceId != 0) { setThumbStrokeColor( AppCompatResources.getColorStateList(getContext(), thumbStrokeColorResourceId)); } } /** * Gets the stroke color for the thumb. * * @return The color used for the stroke in the thumb. * @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeColor * @see #setThumbStrokeColor(ColorStateList) * @see #setThumbStrokeColorResource(int) */ public ColorStateList getThumbStrokeColor() { return thumbDrawable.getStrokeColor(); } /** * Sets the stroke width for the thumb. Both thumbStroke color and thumbStroke width must be set * for a stroke to be drawn. * * @param thumbStrokeWidth Stroke width for the thumb * @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeWidth * @see #setThumbStrokeWidthResource(int) * @see #getThumbStrokeWidth() */ public void setThumbStrokeWidth(float thumbStrokeWidth) { thumbDrawable.setStrokeWidth(thumbStrokeWidth); postInvalidate(); } /** * Sets the stroke width dimension resource for the thumb.Both thumbStroke color and thumbStroke * width must be set for a stroke to be drawn. * * @param thumbStrokeWidthResourceId Stroke width dimension resource for the thumb * @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeWidth * @see #setThumbStrokeWidth(float) * @see #getThumbStrokeWidth() */ public void setThumbStrokeWidthResource(@DimenRes int thumbStrokeWidthResourceId) { if (thumbStrokeWidthResourceId != 0) { setThumbStrokeWidth(getResources().getDimension(thumbStrokeWidthResourceId)); } } /** * Gets the stroke width for the thumb * * @return Stroke width for the thumb. * @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeWidth * @see #setThumbStrokeWidth(float) * @see #setThumbStrokeWidthResource(int) */ public float getThumbStrokeWidth() { return thumbDrawable.getStrokeWidth(); } /** * Returns the radius of the halo. * * @see #setHaloRadius(int) * @see #setHaloRadiusResource(int) * @attr ref com.google.android.material.R.styleable#Slider_haloRadius */ @Dimension() public int getHaloRadius() { return haloRadius; } /** * Sets the radius of the halo in pixels. * * @see #getHaloRadius() * @attr ref com.google.android.material.R.styleable#Slider_haloRadius */ public void setHaloRadius(@IntRange(from = 0) @Dimension int radius) { if (radius == haloRadius) { return; } haloRadius = radius; Drawable background = getBackground(); if (!shouldDrawCompatHalo() && background instanceof RippleDrawable) { DrawableUtils.setRippleDrawableRadius((RippleDrawable) background, haloRadius); return; } postInvalidate(); } /** * Sets the radius of the halo from a dimension resource. * * @see #getHaloRadius() * @attr ref com.google.android.material.R.styleable#Slider_haloRadius */ public void setHaloRadiusResource(@DimenRes int radius) { setHaloRadius(getResources().getDimensionPixelSize(radius)); } /** * Returns the {@link LabelBehavior} used. * * @see #setLabelBehavior(int) * @attr ref com.google.android.material.R.styleable#Slider_labelBehavior */ @LabelBehavior public int getLabelBehavior() { return labelBehavior; } /** * Determines the {@link LabelBehavior} used. * * @see LabelBehavior * @see #getLabelBehavior() * @attr ref com.google.android.material.R.styleable#Slider_labelBehavior */ public void setLabelBehavior(@LabelBehavior int labelBehavior) { if (this.labelBehavior != labelBehavior) { this.labelBehavior = labelBehavior; requestLayout(); } } /** Returns the side padding of the track. */ @Dimension() public int getTrackSidePadding() { return trackSidePadding; } /** Returns the width of the track in pixels. */ @Dimension() public int getTrackWidth() { return trackWidth; } /** * Returns the height of the track in pixels. * * @see #setTrackHeight(int) * @attr ref com.google.android.material.R.styleable#Slider_trackHeight */ @Dimension() public int getTrackHeight() { return trackHeight; } /** * Set the height of the track in pixels. * * @see #getTrackHeight() * @attr ref com.google.android.material.R.styleable#Slider_trackHeight */ public void setTrackHeight(@IntRange(from = 0) @Dimension int trackHeight) { if (this.trackHeight != trackHeight) { this.trackHeight = trackHeight; invalidateTrack(); postInvalidate(); } } /** * Returns the color of the halo. * * @see #setHaloTintList(ColorStateList) * @attr ref com.google.android.material.R.styleable#Slider_haloColor */ @NonNull public ColorStateList getHaloTintList() { return haloColor; } /** * Sets the color of the halo. * * @see #getHaloTintList() * @attr ref com.google.android.material.R.styleable#Slider_haloColor */ public void setHaloTintList(@NonNull ColorStateList haloColor) { if (haloColor.equals(this.haloColor)) { return; } this.haloColor = haloColor; Drawable background = getBackground(); if (!shouldDrawCompatHalo() && background instanceof RippleDrawable) { ((RippleDrawable) background).setColor(haloColor); return; } haloPaint.setColor(getColorForState(haloColor)); haloPaint.setAlpha(HALO_ALPHA); invalidate(); } /** * Returns the color of the thumb. * * @see #setThumbTintList(ColorStateList) * @attr ref com.google.android.material.R.styleable#Slider_thumbColor */ @NonNull public ColorStateList getThumbTintList() { return thumbDrawable.getFillColor(); } /** * Sets the color of the thumb. * * @see #getThumbTintList() * @attr ref com.google.android.material.R.styleable#Slider_thumbColor */ public void setThumbTintList(@NonNull ColorStateList thumbColor) { thumbDrawable.setFillColor(thumbColor); } /** * Returns the color of the tick if the active and inactive parts aren't different. * * @throws IllegalStateException If {@code tickColorActive} and {@code tickColorInactive} have * been set to different values. * @see #setTickTintList(ColorStateList) * @see #setTickInactiveTintList(ColorStateList) * @see #setTickActiveTintList(ColorStateList) * @see #getTickInactiveTintList() * @see #getTickActiveTintList() * @attr ref com.google.android.material.R.styleable#Slider_tickColor */ @NonNull public ColorStateList getTickTintList() { if (!tickColorInactive.equals(tickColorActive)) { throw new IllegalStateException( "The inactive and active ticks are different colors. Use the getTickColorInactive() and" + " getTickColorActive() methods instead."); } return tickColorActive; } /** * Sets the color of the tick marks. * * @see #setTickInactiveTintList(ColorStateList) * @see #setTickActiveTintList(ColorStateList) * @see #getTickTintList() * @attr ref com.google.android.material.R.styleable#Slider_tickColor */ public void setTickTintList(@NonNull ColorStateList tickColor) { setTickInactiveTintList(tickColor); setTickActiveTintList(tickColor); } /** * Returns the color of the ticks on the active portion of the track. * * @see #setTickActiveTintList(ColorStateList) * @see #setTickTintList(ColorStateList) * @see #getTickTintList() * @attr ref com.google.android.material.R.styleable#Slider_tickColorActive */ @NonNull public ColorStateList getTickActiveTintList() { return tickColorActive; } /** * Sets the color of the ticks on the active portion of the track. * * @see #getTickActiveTintList() * @see #setTickTintList(ColorStateList) * @attr ref com.google.android.material.R.styleable#Slider_tickColorActive */ public void setTickActiveTintList(@NonNull ColorStateList tickColor) { if (tickColor.equals(tickColorActive)) { return; } tickColorActive = tickColor; activeTicksPaint.setColor(getColorForState(tickColorActive)); invalidate(); } /** * Returns the color of the ticks on the inactive portion of the track. * * @see #setTickInactiveTintList(ColorStateList) * @see #setTickTintList(ColorStateList) * @see #getTickTintList() * @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive */ @NonNull public ColorStateList getTickInactiveTintList() { return tickColorInactive; } /** * Sets the color of the ticks on the inactive portion of the track. * * @see #getTickInactiveTintList() * @see #setTickTintList(ColorStateList) * @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive */ public void setTickInactiveTintList(@NonNull ColorStateList tickColor) { if (tickColor.equals(tickColorInactive)) { return; } tickColorInactive = tickColor; inactiveTicksPaint.setColor(getColorForState(tickColorInactive)); invalidate(); } /** * Returns whether the tick marks are visible. Only used when the slider is in discrete mode. * * @see #setTickVisible(boolean) * @attr ref com.google.android.material.R.styleable#Slider_tickVisible */ public boolean isTickVisible() { return tickVisible; } /** * Sets whether the tick marks are visible. Only used when the slider is in discrete mode. * * @param tickVisible The visibility of tick marks. * @attr ref com.google.android.material.R.styleable#Slider_tickVisible */ public void setTickVisible(boolean tickVisible) { if (this.tickVisible != tickVisible) { this.tickVisible = tickVisible; postInvalidate(); } } /** * Returns the color of the track if the active and inactive parts aren't different. * * @throws IllegalStateException If {@code trackColorActive} and {@code trackColorInactive} have * been set to different values. * @see #setTrackTintList(ColorStateList) * @see #setTrackInactiveTintList(ColorStateList) * @see #setTrackActiveTintList(ColorStateList) * @see #getTrackInactiveTintList() * @see #getTrackActiveTintList() * @attr ref com.google.android.material.R.styleable#Slider_trackColor */ @NonNull public ColorStateList getTrackTintList() { if (!trackColorInactive.equals(trackColorActive)) { throw new IllegalStateException( "The inactive and active parts of the track are different colors. Use the" + " getInactiveTrackColor() and getActiveTrackColor() methods instead."); } return trackColorActive; } /** * Sets the color of the track. * * @see #setTrackInactiveTintList(ColorStateList) * @see #setTrackActiveTintList(ColorStateList) * @see #getTrackTintList() * @attr ref com.google.android.material.R.styleable#Slider_trackColor */ public void setTrackTintList(@NonNull ColorStateList trackColor) { setTrackInactiveTintList(trackColor); setTrackActiveTintList(trackColor); } /** * Returns the color of the active portion of the track. * * @see #setTrackActiveTintList(ColorStateList) * @see #setTrackTintList(ColorStateList) * @see #getTrackTintList() * @attr ref com.google.android.material.R.styleable#Slider_trackColorActive */ @NonNull public ColorStateList getTrackActiveTintList() { return trackColorActive; } /** * Sets the color of the active portion of the track. * * @see #getTrackActiveTintList() * @see #setTrackTintList(ColorStateList) * @attr ref com.google.android.material.R.styleable#Slider_trackColorActive */ public void setTrackActiveTintList(@NonNull ColorStateList trackColor) { if (trackColor.equals(trackColorActive)) { return; } trackColorActive = trackColor; activeTrackPaint.setColor(getColorForState(trackColorActive)); invalidate(); } /** * Returns the color of the inactive portion of the track. * * @see #setTrackInactiveTintList(ColorStateList) * @see #setTrackTintList(ColorStateList) * @see #getTrackTintList() * @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive */ @NonNull public ColorStateList getTrackInactiveTintList() { return trackColorInactive; } /** * Sets the color of the inactive portion of the track. * * @see #getTrackInactiveTintList() * @see #setTrackTintList(ColorStateList) * @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive */ public void setTrackInactiveTintList(@NonNull ColorStateList trackColor) { if (trackColor.equals(trackColorInactive)) { return; } trackColorInactive = trackColor; inactiveTrackPaint.setColor(getColorForState(trackColorInactive)); invalidate(); } @Override public void setEnabled(boolean enabled) { super.setEnabled(enabled); // When we're disabled, set the layer type to hardware so we can clear the track out from behind // the thumb. setLayerType(enabled ? LAYER_TYPE_NONE : LAYER_TYPE_HARDWARE, null); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); // The label is attached on the Overlay relative to the content. for (TooltipDrawable label : labels) { attachLabelToContentView(label); } } private void attachLabelToContentView(TooltipDrawable label) { label.setRelativeToView(ViewUtils.getContentView(this)); } @Override protected void onDetachedFromWindow() { if (accessibilityEventSender != null) { removeCallbacks(accessibilityEventSender); } for (TooltipDrawable label : labels) { detachLabelFromContentView(label); } super.onDetachedFromWindow(); } private void detachLabelFromContentView(TooltipDrawable label) { ViewOverlayImpl contentViewOverlay = ViewUtils.getContentViewOverlay(this); if (contentViewOverlay != null) { contentViewOverlay.remove(label); label.detachView(ViewUtils.getContentView(this)); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure( widthMeasureSpec, MeasureSpec.makeMeasureSpec( widgetHeight + (labelBehavior == LABEL_WITHIN_BOUNDS ? labels.get(0).getIntrinsicHeight() : 0), MeasureSpec.EXACTLY)); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { // Update the visible track width. trackWidth = Math.max(w - trackSidePadding * 2, 0); // Update the visible tick coordinates. maybeCalculateTicksCoordinates(); updateHaloHotspot(); } private void maybeCalculateTicksCoordinates() { if (stepSize <= 0.0f) { return; } validateConfigurationIfDirty(); int tickCount = (int) ((valueTo - valueFrom) / stepSize + 1); // Limit the tickCount if they will be too dense. tickCount = Math.min(tickCount, trackWidth / (trackHeight * 2) + 1); if (ticksCoordinates == null || ticksCoordinates.length != tickCount * 2) { ticksCoordinates = new float[tickCount * 2]; } float interval = trackWidth / (float) (tickCount - 1); for (int i = 0; i < tickCount * 2; i += 2) { ticksCoordinates[i] = trackSidePadding + i / 2 * interval; ticksCoordinates[i + 1] = calculateTop(); } } private void updateHaloHotspot() { // Set the hotspot as the halo if RippleDrawable is being used. if (!shouldDrawCompatHalo() && getMeasuredWidth() > 0) { final Drawable background = getBackground(); if (background instanceof RippleDrawable) { int x = (int) (normalizeValue(values.get(focusedThumbIdx)) * trackWidth + trackSidePadding); int y = calculateTop(); DrawableCompat.setHotspotBounds( background, x - haloRadius, y - haloRadius, x + haloRadius, y + haloRadius); } } } private int calculateTop() { return trackTop + (labelBehavior == LABEL_WITHIN_BOUNDS ? labels.get(0).getIntrinsicHeight() : 0); } @Override protected void onDraw(@NonNull Canvas canvas) { if (dirtyConfig) { validateConfigurationIfDirty(); // Update the visible tick coordinates. maybeCalculateTicksCoordinates(); } super.onDraw(canvas); int top = calculateTop(); drawInactiveTrack(canvas, trackWidth, top); if (Collections.max(getValues()) > valueFrom) { drawActiveTrack(canvas, trackWidth, top); } maybeDrawTicks(canvas); if ((thumbIsPressed || isFocused()) && isEnabled()) { maybeDrawHalo(canvas, trackWidth, top); // Draw labels if there is an active thumb. if (activeThumbIdx != -1) { ensureLabels(); } } drawThumbs(canvas, trackWidth, top); } /** * Returns a float array where {@code float[0]} is the normalized left position and {@code * float[1]} is the normalized right position of the range. */ private float[] getActiveRange() { float max = Collections.max(getValues()); float min = Collections.min(getValues()); float left = normalizeValue(values.size() == 1 ? valueFrom : min); float right = normalizeValue(max); // In RTL we draw things in reverse, so swap the left and right range values return isRtl() ? new float[] {right, left} : new float[] {left, right}; } private void drawInactiveTrack(@NonNull Canvas canvas, int width, int top) { float[] activeRange = getActiveRange(); float right = trackSidePadding + activeRange[1] * width; if (right < trackSidePadding + width) { canvas.drawLine(right, top, trackSidePadding + width, top, inactiveTrackPaint); } // Also draw inactive track to the left if there is any float left = trackSidePadding + activeRange[0] * width; if (left > trackSidePadding) { canvas.drawLine(trackSidePadding, top, left, top, inactiveTrackPaint); } } /** * Returns a number between 0 and 1 indicating where on the track this value should sit with 0 * being on the far left, and 1 on the far right. */ private float normalizeValue(float value) { float normalized = (value - valueFrom) / (valueTo - valueFrom); if (isRtl()) { return 1 - normalized; } return normalized; } private void drawActiveTrack(@NonNull Canvas canvas, int width, int top) { float[] activeRange = getActiveRange(); float right = trackSidePadding + activeRange[1] * width; float left = trackSidePadding + activeRange[0] * width; canvas.drawLine(left, top, right, top, activeTrackPaint); } private void maybeDrawTicks(@NonNull Canvas canvas) { if (!tickVisible || stepSize <= 0.0f) { return; } float[] activeRange = getActiveRange(); int leftPivotIndex = pivotIndex(ticksCoordinates, activeRange[0]); int rightPivotIndex = pivotIndex(ticksCoordinates, activeRange[1]); // Draw inactive ticks to the left of the smallest thumb. canvas.drawPoints(ticksCoordinates, 0, leftPivotIndex * 2, inactiveTicksPaint); // Draw active ticks between the thumbs. canvas.drawPoints( ticksCoordinates, leftPivotIndex * 2, rightPivotIndex * 2 - leftPivotIndex * 2, activeTicksPaint); // Draw inactive ticks to the right of the largest thumb. canvas.drawPoints( ticksCoordinates, rightPivotIndex * 2, ticksCoordinates.length - rightPivotIndex * 2, inactiveTicksPaint); } private void drawThumbs(@NonNull Canvas canvas, int width, int top) { // Clear out the track behind the thumb if we're in a disable state since the thumb is // transparent. if (!isEnabled()) { for (Float value : values) { canvas.drawCircle( trackSidePadding + normalizeValue(value) * width, top, thumbRadius, thumbPaint); } } for (Float value : values) { canvas.save(); canvas.translate( trackSidePadding + (int) (normalizeValue(value) * width) - thumbRadius, top - thumbRadius); thumbDrawable.draw(canvas); canvas.restore(); } } private void maybeDrawHalo(@NonNull Canvas canvas, int width, int top) { // Only draw the halo for devices that aren't using the ripple. if (shouldDrawCompatHalo()) { int centerX = (int) (trackSidePadding + normalizeValue(values.get(focusedThumbIdx)) * width); if (VERSION.SDK_INT < VERSION_CODES.P) { // In this case we can clip the rect to allow drawing outside the bounds. canvas.clipRect( centerX - haloRadius, top - haloRadius, centerX + haloRadius, top + haloRadius, Op.UNION); } canvas.drawCircle(centerX, top, haloRadius, haloPaint); } } private boolean shouldDrawCompatHalo() { return forceDrawCompatHalo || VERSION.SDK_INT < VERSION_CODES.LOLLIPOP || !(getBackground() instanceof RippleDrawable); } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { if (!isEnabled()) { return false; } float x = event.getX(); touchPosition = (x - trackSidePadding) / trackWidth; touchPosition = Math.max(0, touchPosition); touchPosition = Math.min(1, touchPosition); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: touchDownX = x; // If we're inside a scrolling container, // we should start dragging in ACTION_MOVE if (isInScrollingContainer()) { break; } getParent().requestDisallowInterceptTouchEvent(true); if (!pickActiveThumb()) { // Couldn't determine the active thumb yet. break; } requestFocus(); thumbIsPressed = true; snapTouchPosition(); updateHaloHotspot(); invalidate(); onStartTrackingTouch(); break; case MotionEvent.ACTION_MOVE: if (!thumbIsPressed) { // Check if we're trying to scroll instead of dragging this Slider if (abs(x - touchDownX) < scaledTouchSlop) { return false; } getParent().requestDisallowInterceptTouchEvent(true); onStartTrackingTouch(); } if (!pickActiveThumb()) { // Couldn't determine the active thumb yet. break; } thumbIsPressed = true; snapTouchPosition(); updateHaloHotspot(); invalidate(); break; case MotionEvent.ACTION_UP: thumbIsPressed = false; // We need to handle a tap if the last event was down at the same point. if (lastEvent != null && lastEvent.getActionMasked() == MotionEvent.ACTION_DOWN && abs(lastEvent.getX() - event.getX()) <= scaledTouchSlop && abs(lastEvent.getY() - event.getY()) <= scaledTouchSlop) { pickActiveThumb(); } if (activeThumbIdx != -1) { snapTouchPosition(); activeThumbIdx = -1; } for (TooltipDrawable label : labels) { ViewUtils.getContentViewOverlay(this).remove(label); } onStopTrackingTouch(); invalidate(); break; default: // Nothing to do in this case. } // Set if the thumb is pressed. This will cause the ripple to be drawn. setPressed(thumbIsPressed); lastEvent = MotionEvent.obtain(event); return true; } /** * Calculates the index the closest tick coordinates that the thumb should snap to. * * @param coordinates Tick coordinates defined in {@code #setTicksCoordinates()}. * @param position Actual thumb position. * @return Index of the closest tick coordinate. */ private static int pivotIndex(float[] coordinates, float position) { return Math.round(position * (coordinates.length / 2 - 1)); } private double snapPosition(float position) { if (stepSize > 0.0f) { int stepCount = (int) ((valueTo - valueFrom) / stepSize); return Math.round(position * stepCount) / (double) stepCount; } return position; } /** * Tries to pick the active thumb if one hasn't already been set. This will pick the closest thumb * if there is only one thumb under the touch position. If there is more than one thumb under the * touch position, it will wait for enough drag left or right to determine which thumb to pick. */ protected boolean pickActiveThumb() { if (activeThumbIdx != -1) { return true; } float touchValue = getValueOfTouchPositionAbsolute(); float touchX = valueToX(touchValue); activeThumbIdx = 0; float activeThumbDiff = abs(values.get(activeThumbIdx) - touchValue); for (int i = 1; i < values.size(); i++) { float valueDiff = abs(values.get(i) - touchValue); float valueX = valueToX(values.get(i)); if (compare(valueDiff, activeThumbDiff) > 1) { break; } boolean movingForward = isRtl() ? (valueX - touchX) > 0 : (valueX - touchX) < 0; // Keep replacing the activeThumbIdx, while the diff decreases. // If the diffs are equal we'll pick the thumb based on which direction we are dragging. if (compare(valueDiff, activeThumbDiff) < 0) { activeThumbDiff = valueDiff; activeThumbIdx = i; continue; } if (compare(valueDiff, activeThumbDiff) == 0) { // Two thumbs on the same value and we don't have enough movement to use direction yet. if (abs(valueX - touchX) < scaledTouchSlop) { activeThumbIdx = -1; return false; } if (movingForward) { activeThumbDiff = valueDiff; activeThumbIdx = i; } } } return activeThumbIdx != -1; } private float getValueOfTouchPositionAbsolute() { float position = touchPosition; if (isRtl()) { position = 1 - position; } return (position * (valueTo - valueFrom) + valueFrom); } /** * Snaps the thumb position to the closest tick coordinates in discrete mode, and the input * position in continuous mode. * * @return true, if {@code #thumbPosition is updated}; false, otherwise. */ private boolean snapTouchPosition() { return snapActiveThumbToValue(getValueOfTouchPosition()); } private boolean snapActiveThumbToValue(float value) { return snapThumbToValue(activeThumbIdx, value); } private boolean snapThumbToValue(int idx, float value) { // Check if the new value equals a value that was already set. if (abs(value - values.get(idx)) < THRESHOLD) { return false; } float newValue = getClampedValue(idx, value); // Replace the old value with the new value of the touch position. values.set(idx, newValue); focusedThumbIdx = idx; dispatchOnChangedFromUser(idx); return true; } /** Thumbs cannot cross each other, clamp the value to a bound or the value next to it. */ private float getClampedValue(int idx, float value) { float minSeparation = stepSize == 0 ? getMinSeparation() : 0; minSeparation = separationUnit == UNIT_PX ? dimenToValue(minSeparation) : minSeparation; if (isRtl()) { minSeparation = -minSeparation; } float upperBound = idx + 1 >= values.size() ? valueTo : values.get(idx + 1) - minSeparation; float lowerBound = idx - 1 < 0 ? valueFrom : values.get(idx - 1) + minSeparation; return clamp(value, lowerBound, upperBound); } private float dimenToValue(float dimen) { if (dimen == 0) { return 0; } return ((dimen - trackSidePadding) / trackWidth) * (valueFrom - valueTo) + valueFrom; } protected void setSeparationUnit(int separationUnit) { this.separationUnit = separationUnit; } protected float getMinSeparation() { return 0; } private float getValueOfTouchPosition() { double position = snapPosition(touchPosition); // We might need to invert the touch position to get the correct value. if (isRtl()) { position = 1 - position; } return (float) (position * (valueTo - valueFrom) + valueFrom); } private float valueToX(float value) { return normalizeValue(value) * trackWidth + trackSidePadding; } private void ensureLabels() { if (labelBehavior == LABEL_GONE) { // If the label shouldn't be drawn we can skip this. return; } Iterator labelItr = labels.iterator(); for (int i = 0; i < values.size() && labelItr.hasNext(); i++) { if (i == focusedThumbIdx) { // We position the focused thumb last so it's displayed on top, so skip it for now. continue; } setValueForLabel(labelItr.next(), values.get(i)); } if (!labelItr.hasNext()) { throw new IllegalStateException( String.format( "Not enough labels(%d) to display all the values(%d)", labels.size(), values.size())); } // Now set the label for the focused thumb so it's on top. setValueForLabel(labelItr.next(), values.get(focusedThumbIdx)); } private String formatValue(float value) { if (hasLabelFormatter()) { return formatter.getFormattedValue(value); } return String.format((int) value == value ? "%.0f" : "%.2f", value); } private void setValueForLabel(TooltipDrawable label, float value) { label.setText(formatValue(value)); int left = trackSidePadding + (int) (normalizeValue(value) * trackWidth) - label.getIntrinsicWidth() / 2; int top = calculateTop() - (labelPadding + thumbRadius); label.setBounds(left, top - label.getIntrinsicHeight(), left + label.getIntrinsicWidth(), top); // Calculate the difference between the bounds of this view and the bounds of the root view to // correctly position this view in the overlay layer. Rect rect = new Rect(label.getBounds()); DescendantOffsetUtils.offsetDescendantRect(ViewUtils.getContentView(this), this, rect); label.setBounds(rect); ViewUtils.getContentViewOverlay(this).add(label); } private void invalidateTrack() { inactiveTrackPaint.setStrokeWidth(trackHeight); activeTrackPaint.setStrokeWidth(trackHeight); inactiveTicksPaint.setStrokeWidth(trackHeight / 2.0f); activeTicksPaint.setStrokeWidth(trackHeight / 2.0f); } /** * If this returns true, we can't start dragging the Slider immediately when we receive a {@link * MotionEvent#ACTION_DOWN}. Instead, we must wait for a {@link MotionEvent#ACTION_MOVE}. Copied * from hidden method of {@link View} isInScrollingContainer. * * @return true if any of this View's parents is a scrolling View. */ private boolean isInScrollingContainer() { ViewParent p = getParent(); while (p instanceof ViewGroup) { if (((ViewGroup) p).shouldDelayChildPressedState()) { return true; } p = p.getParent(); } return false; } @SuppressWarnings("unchecked") private void dispatchOnChangedProgramatically() { for (L listener : changeListeners) { for (Float value : values) { listener.onValueChange((S) this, value, false); } } } @SuppressWarnings("unchecked") private void dispatchOnChangedFromUser(int idx) { for (L listener : changeListeners) { listener.onValueChange((S) this, values.get(idx), true); } if (accessibilityManager != null && accessibilityManager.isEnabled()) { scheduleAccessibilityEventSender(idx); } } @SuppressWarnings("unchecked") private void onStartTrackingTouch() { for (T listener : touchListeners) { listener.onStartTrackingTouch((S) this); } } @SuppressWarnings("unchecked") private void onStopTrackingTouch() { for (T listener : touchListeners) { listener.onStopTrackingTouch((S) this); } } @Override protected void drawableStateChanged() { super.drawableStateChanged(); inactiveTrackPaint.setColor(getColorForState(trackColorInactive)); activeTrackPaint.setColor(getColorForState(trackColorActive)); inactiveTicksPaint.setColor(getColorForState(tickColorInactive)); activeTicksPaint.setColor(getColorForState(tickColorActive)); for (TooltipDrawable label : labels) { if (label.isStateful()) { label.setState(getDrawableState()); } } if (thumbDrawable.isStateful()) { thumbDrawable.setState(getDrawableState()); } haloPaint.setColor(getColorForState(haloColor)); haloPaint.setAlpha(HALO_ALPHA); } @ColorInt private int getColorForState(@NonNull ColorStateList colorStateList) { return colorStateList.getColorForState(getDrawableState(), colorStateList.getDefaultColor()); } @VisibleForTesting void forceDrawCompatHalo(boolean force) { forceDrawCompatHalo = force; } @Override public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) { if (!isEnabled()) { return super.onKeyDown(keyCode, event); } // If there's only one thumb, we can select it right away. if (values.size() == 1) { activeThumbIdx = 0; } // If there is no active thumb, key events will be used to pick the thumb to change. if (activeThumbIdx == -1) { Boolean handled = onKeyDownNoActiveThumb(keyCode, event); return handled != null ? handled : super.onKeyDown(keyCode, event); } isLongPress |= event.isLongPress(); Float increment = calculateIncrementForKey(keyCode); if (increment != null) { if (snapActiveThumbToValue(values.get(activeThumbIdx) + increment)) { updateHaloHotspot(); postInvalidate(); } return true; } switch (keyCode) { case KeyEvent.KEYCODE_TAB: if (event.hasNoModifiers()) { return moveFocus(1); } if (event.isShiftPressed()) { return moveFocus(-1); } return false; case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: activeThumbIdx = -1; for (TooltipDrawable label : labels) { ViewUtils.getContentViewOverlay(this).remove(label); } postInvalidate(); return true; default: // Nothing to do in this case. } return super.onKeyDown(keyCode, event); } private Boolean onKeyDownNoActiveThumb(int keyCode, @NonNull KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_TAB: if (event.hasNoModifiers()) { return moveFocus(1); } if (event.isShiftPressed()) { return moveFocus(-1); } return false; case KeyEvent.KEYCODE_DPAD_LEFT: moveFocusInAbsoluteDirection(-1); return true; case KeyEvent.KEYCODE_MINUS: moveFocus(-1); return true; case KeyEvent.KEYCODE_DPAD_RIGHT: moveFocusInAbsoluteDirection(1); return true; case KeyEvent.KEYCODE_EQUALS: // Numpad Plus == Shift + Equals, at least in AVD, so fall through. case KeyEvent.KEYCODE_PLUS: moveFocus(1); return true; case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: activeThumbIdx = focusedThumbIdx; postInvalidate(); return true; default: // Nothing to do in this case. } return null; } @Override public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { isLongPress = false; return super.onKeyUp(keyCode, event); } final boolean isRtl() { return ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL; } /** * Attempts to move focus to next or previous thumb independent of layout direction and * returns whether the focused thumb changed. If focused thumb didn't change, we're at the view * boundary for specified {@code direction} and focus may be moved to next or previous view * instead. * * @see #moveFocusInAbsoluteDirection(int) */ private boolean moveFocus(int direction) { int oldFocusedThumbIdx = focusedThumbIdx; // Prevent integer overflow. final long newFocusedThumbIdx = (long) oldFocusedThumbIdx + direction; focusedThumbIdx = (int) clamp(newFocusedThumbIdx, 0, values.size() - 1); if (focusedThumbIdx == oldFocusedThumbIdx) { // Move focus to next or previous view. return false; } if (activeThumbIdx != -1) { activeThumbIdx = focusedThumbIdx; } updateHaloHotspot(); postInvalidate(); return true; } /** * Attempts to move focus to the left or right of currently focused thumb and returns * whether the focused thumb changed. If focused thumb didn't change, we're at the view boundary * for specified {@code direction} and focus may be moved to next or previous view instead. * * @see #moveFocus(int) */ private boolean moveFocusInAbsoluteDirection(int direction) { if (isRtl()) { // Prevent integer overflow. direction = direction == Integer.MIN_VALUE ? Integer.MAX_VALUE : -direction; } return moveFocus(direction); } private Float calculateIncrementForKey(int keyCode) { // If this is a long press, increase the increment so it will only take around 20 steps. // Otherwise choose the smallest valid increment. float increment = isLongPress ? calculateStepIncrement(20) : calculateStepIncrement(); switch (keyCode) { case KeyEvent.KEYCODE_DPAD_LEFT: return isRtl() ? increment : -increment; case KeyEvent.KEYCODE_DPAD_RIGHT: return isRtl() ? -increment : increment; case KeyEvent.KEYCODE_MINUS: return -increment; case KeyEvent.KEYCODE_EQUALS: // Numpad Plus == Shift + Equals, at least in AVD, so fall through. case KeyEvent.KEYCODE_PLUS: return increment; default: return null; } } /** Returns a small valid step increment to use when adding an offset to an existing value */ private float calculateStepIncrement() { return stepSize == 0 ? 1 : stepSize; } /** * Returns a valid increment based on the {@code stepSize} (if it's set) that will allow * approximately {@code stepFactor} steps to cover the whole range. */ private float calculateStepIncrement(int stepFactor) { float increment = calculateStepIncrement(); float numSteps = (valueTo - valueFrom) / increment; if (numSteps <= stepFactor) { return increment; } return Math.round((numSteps / stepFactor)) * increment; } @Override protected void onFocusChanged( boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) { super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); if (!gainFocus) { activeThumbIdx = -1; for (TooltipDrawable label : labels) { ViewUtils.getContentViewOverlay(this).remove(label); } accessibilityHelper.clearKeyboardFocusForVirtualView(focusedThumbIdx); } else { focusThumbOnFocusGained(direction); accessibilityHelper.requestKeyboardFocusForVirtualView(focusedThumbIdx); } } private void focusThumbOnFocusGained(int direction) { switch (direction) { case FOCUS_BACKWARD: moveFocus(Integer.MAX_VALUE); break; case FOCUS_LEFT: moveFocusInAbsoluteDirection(Integer.MAX_VALUE); break; case FOCUS_FORWARD: moveFocus(Integer.MIN_VALUE); break; case FOCUS_RIGHT: moveFocusInAbsoluteDirection(Integer.MIN_VALUE); break; case FOCUS_UP: case FOCUS_DOWN: default: // Don't make assumptions about where exactly focus came from. Use previously focused thumb. } } @VisibleForTesting final int getAccessibilityFocusedVirtualViewId() { return accessibilityHelper.getAccessibilityFocusedVirtualViewId(); } @NonNull @Override public CharSequence getAccessibilityClassName() { return SeekBar.class.getName(); } @Override public boolean dispatchHoverEvent(@NonNull MotionEvent event) { return accessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event); } @Override public boolean dispatchKeyEvent(@NonNull KeyEvent event) { // We explicitly don't pass the key event to the accessibilityHelper because it doesn't handle // focus correctly in some cases (Such as moving left after moving right a few times). return super.dispatchKeyEvent(event); } /** * Schedule a command for sending an accessibility event.
Note: A command is used to ensure * that accessibility events are sent at most one in a given time frame to save system resources * while the value changes quickly. */ private void scheduleAccessibilityEventSender(int idx) { if (accessibilityEventSender == null) { accessibilityEventSender = new AccessibilityEventSender(); } else { removeCallbacks(accessibilityEventSender); } accessibilityEventSender.setVirtualViewId(idx); postDelayed(accessibilityEventSender, TIMEOUT_SEND_ACCESSIBILITY_EVENT); } /** Command for sending an accessibility event. */ private class AccessibilityEventSender implements Runnable { int virtualViewId = -1; void setVirtualViewId(int virtualViewId) { this.virtualViewId = virtualViewId; } @Override public void run() { accessibilityHelper.sendEventForVirtualView( virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED); } } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SliderState sliderState = new SliderState(superState); sliderState.valueFrom = valueFrom; sliderState.valueTo = valueTo; sliderState.values = new ArrayList<>(values); sliderState.stepSize = stepSize; sliderState.hasFocus = hasFocus(); return sliderState; } @Override protected void onRestoreInstanceState(Parcelable state) { SliderState sliderState = (SliderState) state; super.onRestoreInstanceState(sliderState.getSuperState()); valueFrom = sliderState.valueFrom; valueTo = sliderState.valueTo; setValuesInternal(sliderState.values); stepSize = sliderState.stepSize; if (sliderState.hasFocus) { requestFocus(); } dispatchOnChangedProgramatically(); } static class SliderState extends BaseSavedState { float valueFrom; float valueTo; ArrayList values; float stepSize; boolean hasFocus; public static final Creator CREATOR = new Creator() { @NonNull @Override public SliderState createFromParcel(@NonNull Parcel source) { return new SliderState(source); } @NonNull @Override public SliderState[] newArray(int size) { return new SliderState[size]; } }; SliderState(Parcelable superState) { super(superState); } private SliderState(@NonNull Parcel source) { super(source); valueFrom = source.readFloat(); valueTo = source.readFloat(); values = new ArrayList<>(); source.readList(values, Float.class.getClassLoader()); stepSize = source.readFloat(); hasFocus = source.createBooleanArray()[0]; } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeFloat(valueFrom); dest.writeFloat(valueTo); dest.writeList(values); dest.writeFloat(stepSize); boolean[] booleans = new boolean[1]; booleans[0] = hasFocus; dest.writeBooleanArray(booleans); } } void updateBoundsForVirturalViewId(int virtualViewId, Rect virtualViewBounds) { int x = trackSidePadding + (int) (normalizeValue(getValues().get(virtualViewId)) * trackWidth); int y = calculateTop(); virtualViewBounds.set(x - thumbRadius, y - thumbRadius, x + thumbRadius, y + thumbRadius); } private static class AccessibilityHelper extends ExploreByTouchHelper { private final BaseSlider slider; Rect virtualViewBounds = new Rect(); AccessibilityHelper(BaseSlider slider) { super(slider); this.slider = slider; } @Override protected int getVirtualViewAt(float x, float y) { for (int i = 0; i < slider.getValues().size(); i++) { slider.updateBoundsForVirturalViewId(i, virtualViewBounds); if (virtualViewBounds.contains((int) x, (int) y)) { return i; } } return ExploreByTouchHelper.HOST_ID; } @Override protected void getVisibleVirtualViews(List virtualViewIds) { for (int i = 0; i < slider.getValues().size(); i++) { virtualViewIds.add(i); } } @Override protected void onPopulateNodeForVirtualView( int virtualViewId, AccessibilityNodeInfoCompat info) { info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_PROGRESS); List values = slider.getValues(); final float value = values.get(virtualViewId); float valueFrom = slider.getValueFrom(); float valueTo = slider.getValueTo(); if (slider.isEnabled()) { if (value > valueFrom) { info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); } if (value < valueTo) { info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); } } info.setRangeInfo(RangeInfoCompat.obtain(RANGE_TYPE_FLOAT, valueFrom, valueTo, value)); info.setClassName(SeekBar.class.getName()); StringBuilder contentDescription = new StringBuilder(); // Add the content description of the slider. if (slider.getContentDescription() != null) { contentDescription.append(slider.getContentDescription()).append(","); } // Add the range to the content description. if (values.size() > 1) { contentDescription.append(startOrEndDescription(virtualViewId)); contentDescription.append(slider.formatValue(value)); } info.setContentDescription(contentDescription.toString()); slider.updateBoundsForVirturalViewId(virtualViewId, virtualViewBounds); info.setBoundsInParent(virtualViewBounds); } @NonNull private String startOrEndDescription(int virtualViewId) { List values = slider.getValues(); if (virtualViewId == values.size() - 1) { return slider.getContext().getString(R.string.material_slider_range_end); } if (virtualViewId == 0) { return slider.getContext().getString(R.string.material_slider_range_start); } return ""; } @Override protected boolean onPerformActionForVirtualView( int virtualViewId, int action, Bundle arguments) { if (!slider.isEnabled()) { return false; } switch (action) { case android.R.id.accessibilityActionSetProgress: { if (arguments == null || !arguments.containsKey( AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE)) { return false; } float value = arguments.getFloat(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE); if (slider.snapThumbToValue(virtualViewId, value)) { slider.updateHaloHotspot(); slider.postInvalidate(); invalidateVirtualView(virtualViewId); return true; } return false; } case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD: case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD: { float increment = slider.calculateStepIncrement(20); if (action == AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD) { increment = -increment; } // Swap the increment if we're in RTL. if (slider.isRtl()) { increment = -increment; } List values = slider.getValues(); float clamped = clamp( values.get(virtualViewId) + increment, slider.getValueFrom(), slider.getValueTo()); if (slider.snapThumbToValue(virtualViewId, clamped)) { slider.updateHaloHotspot(); slider.postInvalidate(); invalidateVirtualView(virtualViewId); return true; } return false; } default: return false; } } } }