/* * 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 android.view.accessibility.AccessibilityManager.FLAG_CONTENT_CONTROLS; import static android.view.accessibility.AccessibilityManager.FLAG_CONTENT_TEXT; import static androidx.core.math.MathUtils.clamp; import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT; import static com.google.android.material.shape.CornerFamily.ROUNDED; 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_VISIBLE; import static com.google.android.material.slider.LabelFormatter.LABEL_WITHIN_BOUNDS; import static com.google.android.material.slider.SliderOrientation.HORIZONTAL; import static com.google.android.material.slider.SliderOrientation.VERTICAL; import static com.google.android.material.slider.TickVisibilityMode.TICK_VISIBILITY_AUTO_HIDE; import static com.google.android.material.slider.TickVisibilityMode.TICK_VISIBILITY_AUTO_LIMIT; import static com.google.android.material.slider.TickVisibilityMode.TICK_VISIBILITY_HIDDEN; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import static java.lang.Float.compare; import static java.lang.Math.abs; import static java.lang.Math.max; import static java.lang.Math.min; import static java.math.MathContext.DECIMAL64; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; 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.Matrix; import android.graphics.Paint; import android.graphics.Paint.Cap; import android.graphics.Paint.Style; import android.graphics.Path; import android.graphics.Path.Direction; import android.graphics.PorterDuff.Mode; import android.graphics.PorterDuffXfermode; import android.graphics.Rect; import android.graphics.RectF; 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.appcompat.content.res.AppCompatResources; import android.text.TextUtils; 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.ViewOverlay; import android.view.ViewParent; import android.view.ViewTreeObserver; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.SeekBar; import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.VisibleForTesting; 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.customview.widget.ExploreByTouchHelper; import com.google.android.material.animation.AnimationUtils; 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.ViewUtils; import com.google.android.material.motion.MotionUtils; import com.google.android.material.resources.MaterialAttributes; import com.google.android.material.resources.MaterialResources; import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.tooltip.TooltipDrawable; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.math.BigDecimal; import java.text.NumberFormat; import java.text.ParseException; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Locale; /** * 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: * *
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 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 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 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, stepSize, valueFrom, valueTo));
}
if (this.stepSize != stepSize) {
this.stepSize = stepSize;
dirtyConfig = true;
postInvalidate();
}
}
/**
* Returns the tick count used in continuous mode.
*
* @see #setContinuousModeTickCount(int)
* @attr ref com.google.android.material.R.styleable#Slider_continuousModeTickCount
*/
public int getContinuousModeTickCount() {
return continuousModeTickCount;
}
/**
* Sets the number of ticks to display in continuous mode. Default is 0.
*
* This allows for showing purely visual ticks in continuous mode.
*
* Setting this value to a negative value will result in an {@link IllegalArgumentException}.
*
* @param continuousModeTickCount The number of ticks that must be drawn in continuous mode count
* @throws IllegalArgumentException If the continuous mode tick count is less than 0
* @see #getContinuousModeTickCount()
* @attr ref com.google.android.material.R.styleable#Slider_continuousModeTickCount
*/
public void setContinuousModeTickCount(int continuousModeTickCount) {
if (continuousModeTickCount < 0) {
throw new IllegalArgumentException(
String.format(EXCEPTION_ILLEGAL_CONTINUOUS_MODE_TICK_COUNT, continuousModeTickCount));
}
if (this.continuousModeTickCount != continuousModeTickCount) {
this.continuousModeTickCount = continuousModeTickCount;
dirtyConfig = true;
postInvalidate();
}
}
/**
* Sets the custom thumb drawable which will be used for all value positions. Note that the custom
* drawable provided will be resized to match the thumb radius set by {@link #setThumbRadius(int)}
* or {@link #setThumbRadiusResource(int)}. Be aware that the image quality may be compromised
* during resizing.
*
* @see #setCustomThumbDrawable(Drawable)
* @see #setCustomThumbDrawablesForValues(int...)
* @see #setCustomThumbDrawablesForValues(Drawable...)
*/
void setCustomThumbDrawable(@DrawableRes int drawableResId) {
setCustomThumbDrawable(getResources().getDrawable(drawableResId));
}
/**
* Sets the custom thumb drawable which will be used for all value positions. Note that the custom
* drawable provided will be resized to match the thumb radius set by {@link #setThumbRadius(int)}
* or {@link #setThumbRadiusResource(int)}. Be aware that the image quality may be compromised
* during resizing.
*
* @see #setCustomThumbDrawable(int)
* @see #setCustomThumbDrawablesForValues(int...)
* @see #setCustomThumbDrawablesForValues(Drawable...)
*/
void setCustomThumbDrawable(@NonNull Drawable drawable) {
customThumbDrawable = initializeCustomThumbDrawable(drawable);
customThumbDrawablesForValues.clear();
postInvalidate();
}
/**
* Sets custom thumb drawables. The drawables provided will be used in its corresponding value
* position - i.e., the first drawable will be used to indicate the first value, and so on. If the
* number of drawables is less than the number of values, the default drawable will be used for
* the remaining values.
*
* Note that the custom drawables provided will be resized to match the thumb radius set by
* {@link #setThumbRadius(int)} or {@link #setThumbRadiusResource(int)}. Be aware that the image
* quality may be compromised during resizing.
*
* @see #setCustomThumbDrawablesForValues(Drawable...)
*/
void setCustomThumbDrawablesForValues(@NonNull @DrawableRes int... customThumbDrawableResIds) {
Drawable[] customThumbDrawables = new Drawable[customThumbDrawableResIds.length];
for (int i = 0; i < customThumbDrawableResIds.length; i++) {
customThumbDrawables[i] = getResources().getDrawable(customThumbDrawableResIds[i]);
}
setCustomThumbDrawablesForValues(customThumbDrawables);
}
/**
* Sets custom thumb drawables. The drawables provided will be used in its corresponding value
* position - i.e., the first drawable will be used to indicate the first value, and so on. If the
* number of drawables is less than the number of values, the default drawable will be used for
* the remaining values.
*
* Note that the custom drawables provided will be resized to match the thumb radius set by
* {@link #setThumbRadius(int)} or {@link #setThumbRadiusResource(int)}. Be aware that the image
* quality may be compromised during resizing.
*
* @see #setCustomThumbDrawablesForValues(int...)
*/
void setCustomThumbDrawablesForValues(@NonNull Drawable... customThumbDrawables) {
this.customThumbDrawable = null;
this.customThumbDrawablesForValues = new ArrayList<>();
for (Drawable originalDrawable : customThumbDrawables) {
this.customThumbDrawablesForValues.add(initializeCustomThumbDrawable(originalDrawable));
}
postInvalidate();
}
private Drawable initializeCustomThumbDrawable(Drawable originalDrawable) {
Drawable drawable = originalDrawable.mutate().getConstantState().newDrawable();
adjustCustomThumbDrawableBounds(drawable);
return drawable;
}
private void adjustCustomThumbDrawableBounds(Drawable drawable) {
adjustCustomThumbDrawableBounds(thumbWidth, drawable);
}
private void adjustCustomThumbDrawableBounds(
@IntRange(from = 0) @Px int width, Drawable drawable) {
int originalWidth = drawable.getIntrinsicWidth();
int originalHeight = drawable.getIntrinsicHeight();
if (originalWidth == -1 && originalHeight == -1) {
drawable.setBounds(0, 0, width, thumbHeight);
} else {
float scaleRatio = (float) max(width, thumbHeight) / max(originalWidth, originalHeight);
drawable.setBounds(
0, 0, (int) (originalWidth * scaleRatio), (int) (originalHeight * scaleRatio));
}
}
/** 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. On the RangeSlider implementation,
* the listener is invoked once for each value.
*
* @param listener The callback to run when the slider changes
*/
public void addOnChangeListener(@NonNull 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 thumbElevation;
}
/**
* Sets the elevation of the thumb.
*
* @see #getThumbElevation()
* @attr ref com.google.android.material.R.styleable#Slider_thumbElevation
*/
public void setThumbElevation(float elevation) {
if (elevation == thumbElevation) {
return;
}
thumbElevation = elevation;
for (int i = 0; i < defaultThumbDrawables.size(); i++) {
defaultThumbDrawables.get(i).setElevation(thumbElevation);
}
}
/**
* 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. Note that setting this will also affect custom drawables set
* through {@link #setCustomThumbDrawable(int)}, {@link #setCustomThumbDrawable(Drawable)}, {@link
* #setCustomThumbDrawablesForValues(int...)}, and {@link
* #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #setThumbRadius(int)
* @see #setThumbRadiusResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
*/
@Px
public int getThumbRadius() {
return thumbWidth / 2;
}
/**
* Sets the radius of the thumb in pixels. Note that setting this will also affect custom
* drawables set through {@link #setCustomThumbDrawable(int)}, {@link
* #setCustomThumbDrawable(Drawable)}, {@link #setCustomThumbDrawablesForValues(int...)}, and
* {@link #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #getThumbRadius()
* @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
*/
public void setThumbRadius(@IntRange(from = 0) @Px int radius) {
setThumbWidth(radius * 2);
setThumbHeight(radius * 2);
}
/**
* Sets the radius of the thumb from a dimension resource. Note that setting this will also affect
* custom drawables set through {@link #setCustomThumbDrawable(int)}, {@link
* #setCustomThumbDrawable(Drawable)}, {@link #setCustomThumbDrawablesForValues(int...)}, and
* {@link #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #getThumbRadius()
* @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
*/
public void setThumbRadiusResource(@DimenRes int radius) {
setThumbRadius(getResources().getDimensionPixelSize(radius));
}
/**
* Returns the width of the thumb. Note that setting this will also affect custom drawables set
* through {@link #setCustomThumbDrawable(int)}, {@link #setCustomThumbDrawable(Drawable)}, {@link
* #setCustomThumbDrawablesForValues(int...)}, and {@link
* #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #setThumbWidth(int)
* @see #setThumbWidthResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_thumbWidth
*/
@Px
public int getThumbWidth() {
return thumbWidth;
}
/**
* Sets the width of the thumb in pixels. Note that setting this will also affect custom drawables
* set through {@link #setCustomThumbDrawable(int)}, {@link #setCustomThumbDrawable(Drawable)},
* {@link #setCustomThumbDrawablesForValues(int...)}, and {@link
* #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #getThumbWidth()
* @attr ref com.google.android.material.R.styleable#Slider_thumbWidth
*/
public void setThumbWidth(@IntRange(from = 0) @Px int width) {
if (width == thumbWidth) {
return;
}
thumbWidth = width;
// Update custom thumbs, if any.
if (customThumbDrawable != null) {
adjustCustomThumbDrawableBounds(width, customThumbDrawable);
}
for (int i = 0; i < customThumbDrawablesForValues.size(); i++) {
adjustCustomThumbDrawableBounds(width, customThumbDrawablesForValues.get(i));
}
// Update default thumb(s).
setThumbWidth(width, /* thumbIndex= */ null);
}
private void setThumbWidth(@IntRange(from = 0) @Px int width, @Nullable Integer thumbIndex) {
for (int i = 0; i < defaultThumbDrawables.size(); i++) {
if (thumbIndex == null || i == thumbIndex) {
defaultThumbDrawables
.get(i)
.setShapeAppearanceModel(
ShapeAppearanceModel.builder().setAllCorners(ROUNDED, width / 2f).build());
defaultThumbDrawables.get(i).setBounds(0, 0, width, thumbHeight);
}
}
updateWidgetLayout(false);
}
/**
* Sets the width of the thumb from a dimension resource. Note that setting this will also affect
* custom drawables set through {@link #setCustomThumbDrawable(int)}, {@link
* #setCustomThumbDrawable(Drawable)}, {@link #setCustomThumbDrawablesForValues(int...)}, and
* {@link #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #getThumbWidth()
* @attr ref com.google.android.material.R.styleable#Slider_thumbWidth
*/
public void setThumbWidthResource(@DimenRes int width) {
setThumbWidth(getResources().getDimensionPixelSize(width));
}
/**
* Returns the height of the thumb. Note that setting this will also affect custom drawables set
* through {@link #setCustomThumbDrawable(int)}, {@link #setCustomThumbDrawable(Drawable)}, {@link
* #setCustomThumbDrawablesForValues(int...)}, and {@link
* #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #setThumbHeight(int)
* @see #setThumbHeightResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_thumbHeight
*/
@Px
public int getThumbHeight() {
return thumbHeight;
}
/**
* Sets the height of the thumb in pixels. Note that setting this will also affect custom
* drawables set through {@link #setCustomThumbDrawable(int)}, {@link
* #setCustomThumbDrawable(Drawable)}, {@link #setCustomThumbDrawablesForValues(int...)}, and
* {@link #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #getThumbHeight()
* @attr ref com.google.android.material.R.styleable#Slider_thumbHeight
*/
public void setThumbHeight(@IntRange(from = 0) @Px int height) {
if (height == thumbHeight) {
return;
}
thumbHeight = height;
for (int i = 0; i < defaultThumbDrawables.size(); i++) {
defaultThumbDrawables.get(i).setBounds(0, 0, thumbWidth, thumbHeight);
}
if (customThumbDrawable != null) {
adjustCustomThumbDrawableBounds(customThumbDrawable);
}
for (Drawable customDrawable : customThumbDrawablesForValues) {
adjustCustomThumbDrawableBounds(customDrawable);
}
updateWidgetLayout(false);
}
/**
* Sets the height of the thumb from a dimension resource. Note that setting this will also affect
* custom drawables set through {@link #setCustomThumbDrawable(int)}, {@link
* #setCustomThumbDrawable(Drawable)}, {@link #setCustomThumbDrawablesForValues(int...)}, and
* {@link #setCustomThumbDrawablesForValues(Drawable...)}.
*
* @see #getThumbHeight()
* @attr ref com.google.android.material.R.styleable#Slider_thumbHeight
*/
public void setThumbHeightResource(@DimenRes int height) {
setThumbHeight(getResources().getDimensionPixelSize(height));
}
/**
* 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) {
if (thumbStrokeColor == this.thumbStrokeColor) {
return;
}
this.thumbStrokeColor = thumbStrokeColor;
for (int i = 0; i < defaultThumbDrawables.size(); i++) {
defaultThumbDrawables.get(i).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)
*/
@Nullable
public ColorStateList getThumbStrokeColor() {
return thumbStrokeColor;
}
/**
* 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) {
if (thumbStrokeWidth == this.thumbStrokeWidth) {
return;
}
this.thumbStrokeWidth = thumbStrokeWidth;
for (int i = 0; i < defaultThumbDrawables.size(); i++) {
defaultThumbDrawables.get(i).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 thumbStrokeWidth;
}
/**
* Returns the radius of the halo.
*
* @see #setHaloRadius(int)
* @see #setHaloRadiusResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_haloRadius
*/
@Px
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) @Px 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;
updateWidgetLayout(true);
}
}
/**
* Returns whether the labels should be always shown based on the {@link LabelBehavior}.
*
* @see LabelBehavior
* @attr ref com.google.android.material.R.styleable#Slider_labelBehavior
*/
private boolean shouldAlwaysShowLabel() {
return this.labelBehavior == LABEL_VISIBLE;
}
/** Returns the side padding of the track. */
@Px
public int getTrackSidePadding() {
return trackSidePadding;
}
/** Returns the width of the track in pixels. */
@Px
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
*/
@Px
public int getTrackHeight() {
return trackThickness;
}
/**
* 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) @Px int trackHeight) {
if (this.trackThickness != trackHeight) {
this.trackThickness = trackHeight;
invalidateTrack();
updateWidgetLayout(false);
}
}
/**
* Returns the radius of the active tick in pixels.
*
* @attr ref com.google.android.material.R.styleable#Slider_activeTickRadius
* @see #setTickActiveRadius(int)
*/
@Px
public int getTickActiveRadius() {
return tickActiveRadius;
}
/**
* Set the radius of the active tick in pixels.
*
* @attr ref com.google.android.material.R.styleable#Slider_activeTickRadius
* @see #getTickActiveRadius()
*/
public void setTickActiveRadius(@IntRange(from = 0) @Px int tickActiveRadius) {
if (this.tickActiveRadius != tickActiveRadius) {
this.tickActiveRadius = tickActiveRadius;
activeTicksPaint.setStrokeWidth(tickActiveRadius * 2);
updateWidgetLayout(false);
}
}
/**
* Returns the radius of the inactive tick in pixels.
*
* @attr ref com.google.android.material.R.styleable#Slider_inactiveTickRadius
* @see #setTickInactiveRadius(int)
*/
@Px
public int getTickInactiveRadius() {
return tickInactiveRadius;
}
/**
* Set the radius of the inactive tick in pixels.
*
* @attr ref com.google.android.material.R.styleable#Slider_inactiveTickRadius
* @see #getTickInactiveRadius()
*/
public void setTickInactiveRadius(@IntRange(from = 0) @Px int tickInactiveRadius) {
if (this.tickInactiveRadius != tickInactiveRadius) {
this.tickInactiveRadius = tickInactiveRadius;
inactiveTicksPaint.setStrokeWidth(tickInactiveRadius * 2);
updateWidgetLayout(false);
}
}
private void updateWidgetLayout(boolean forceRefresh) {
boolean sizeChanged = maybeIncreaseWidgetThickness();
boolean sidePaddingChanged = maybeIncreaseTrackSidePadding();
if (isVertical()) {
updateRotationMatrix();
}
if (sizeChanged || forceRefresh) {
requestLayout();
} else if (sidePaddingChanged) {
postInvalidate();
}
}
private boolean maybeIncreaseWidgetThickness() {
int paddings;
if (isVertical()) {
paddings = getPaddingLeft() + getPaddingRight();
} else {
paddings = getPaddingTop() + getPaddingBottom();
}
int minHeightRequiredByTrack = trackThickness + paddings;
int minHeightRequiredByThumb = thumbHeight + paddings;
int newWidgetHeight =
max(minWidgetThickness, max(minHeightRequiredByTrack, minHeightRequiredByThumb));
if (newWidgetHeight == widgetThickness) {
return false;
}
widgetThickness = newWidgetHeight;
return true;
}
private void updateRotationMatrix() {
float pivot = calculateTrackCenter();
rotationMatrix.reset();
rotationMatrix.setRotate(90, pivot, pivot);
}
/**
* 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 thumbTintList;
}
/**
* Sets the color of the thumb.
*
* @see #getThumbTintList()
* @attr ref com.google.android.material.R.styleable#Slider_thumbColor
*/
public void setThumbTintList(@NonNull ColorStateList thumbColor) {
if (thumbColor.equals(thumbTintList)) {
return;
}
thumbTintList = thumbColor;
for (int i = 0; i < defaultThumbDrawables.size(); i++) {
defaultThumbDrawables.get(i).setFillColor(thumbTintList);
}
invalidate();
}
/**
* 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.
*
* @attr ref com.google.android.material.R.styleable#Slider_tickVisible
*/
public boolean isTickVisible() {
switch (tickVisibilityMode) {
case TICK_VISIBILITY_AUTO_LIMIT:
return true;
case TICK_VISIBILITY_AUTO_HIDE:
return getDesiredTickCount() <= getMaxTickCount();
case TICK_VISIBILITY_HIDDEN:
return false;
default:
throw new IllegalStateException("Unexpected tickVisibilityMode: " + tickVisibilityMode);
}
}
/**
* 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
* @deprecated Use {@link #setTickVisibilityMode(int)} instead.
*/
@Deprecated
public void setTickVisible(boolean tickVisible) {
setTickVisibilityMode(convertToTickVisibilityMode(tickVisible));
}
@TickVisibilityMode
private int convertToTickVisibilityMode(boolean tickVisible) {
return tickVisible ? TICK_VISIBILITY_AUTO_LIMIT : TICK_VISIBILITY_HIDDEN;
}
/**
* Returns the current tick visibility mode.
*
* @see #setTickVisibilityMode(int)
* @attr ref com.google.android.material.R.styleable#Slider_tickVisibilityMode
*/
@TickVisibilityMode
public int getTickVisibilityMode() {
return tickVisibilityMode;
}
/**
* Sets the tick visibility mode. Only used when the slider is in discrete mode.
*
* @see #getTickVisibilityMode()
* @attr ref com.google.android.material.R.styleable#Slider_tickVisibilityMode
*/
public void setTickVisibilityMode(@TickVisibilityMode int tickVisibilityMode) {
if (this.tickVisibilityMode != tickVisibilityMode) {
this.tickVisibilityMode = tickVisibilityMode;
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();
}
/**
* Returns the size of the gap between the thumb and the track.
*
* @see #setThumbTrackGapSize(int)
* @attr ref com.google.android.material.R.styleable#Slider_thumbTrackGapSize
*/
public int getThumbTrackGapSize() {
return thumbTrackGapSize;
}
/**
* Sets the size of the gap between the thumb and the track.
*
* @see #getThumbTrackGapSize()
* @attr ref com.google.android.material.R.styleable#Slider_thumbTrackGapSize
*/
public void setThumbTrackGapSize(@Px int thumbTrackGapSize) {
if (this.thumbTrackGapSize == thumbTrackGapSize) {
return;
}
this.thumbTrackGapSize = thumbTrackGapSize;
invalidate();
}
/**
* Returns the size of the stop indicator at the edges of the track.
*
* @see #setTrackStopIndicatorSize(int)
* @attr ref com.google.android.material.R.styleable#Slider_trackStopIndicatorSize
*/
public int getTrackStopIndicatorSize() {
return trackStopIndicatorSize;
}
/**
* Sets the size of the stop indicator at the edges of the track.
*
* @see #getTrackStopIndicatorSize()
* @attr ref com.google.android.material.R.styleable#Slider_trackStopIndicatorSize
*/
public void setTrackStopIndicatorSize(@Px int trackStopIndicatorSize) {
if (this.trackStopIndicatorSize == trackStopIndicatorSize) {
return;
}
this.trackStopIndicatorSize = trackStopIndicatorSize;
stopIndicatorPaint.setStrokeWidth(trackStopIndicatorSize);
invalidate();
}
/**
* Returns the corner size on the outside of the track.
*
* @see #setTrackCornerSize(int)
* @attr ref com.google.android.material.R.styleable#Slider_trackCornerSize
*/
@Px
public int getTrackCornerSize() {
if (trackCornerSize == TRACK_CORNER_SIZE_UNSET) {
return trackThickness / 2; // full rounded corners by default when unset
}
return trackCornerSize;
}
/**
* Sets the corner size on the outside of the track.
*
* @see #getTrackCornerSize()
* @attr ref com.google.android.material.R.styleable#Slider_trackCornerSize
*/
public void setTrackCornerSize(@Px int cornerSize) {
if (this.trackCornerSize == cornerSize) {
return;
}
this.trackCornerSize = cornerSize;
invalidate();
}
/**
* Returns the corner size on the inside of the track (visible with gap).
*
* @see #setTrackInsideCornerSize(int)
* @attr ref com.google.android.material.R.styleable#Slider_trackInsideCornerSize
*/
public int getTrackInsideCornerSize() {
return trackInsideCornerSize;
}
/**
* Sets the corner size on the inside of the track (visible with gap).
*
* @see #getTrackInsideCornerSize()
* @attr ref com.google.android.material.R.styleable#Slider_trackInsideCornerSize
*/
public void setTrackInsideCornerSize(@Px int cornerSize) {
if (this.trackInsideCornerSize == cornerSize) {
return;
}
this.trackInsideCornerSize = cornerSize;
invalidate();
}
/**
* Sets the active track start icon.
*
* @param icon Drawable to use for the active track's start icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveStart
* @see #setTrackIconActiveStart(int)
* @see #getTrackIconActiveStart()
*/
public void setTrackIconActiveStart(@Nullable Drawable icon) {
if (icon == trackIconActiveStart) {
return;
}
trackIconActiveStart = icon;
trackIconActiveStartMutated = false;
updateTrackIconActiveStart();
invalidate();
}
private void updateTrackIconActiveStart() {
if (trackIconActiveStart != null) {
if (!trackIconActiveStartMutated && trackIconActiveColor != null) {
trackIconActiveStart = DrawableCompat.wrap(trackIconActiveStart).mutate();
trackIconActiveStartMutated = true;
}
if (trackIconActiveStartMutated) {
trackIconActiveStart.setTintList(trackIconActiveColor);
}
}
}
/**
* Sets the active track start icon.
*
* @param iconResourceId Drawable resource ID to use for the active track's start icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveStart
* @see #setTrackIconActiveStart(Drawable)
* @see #getTrackIconActiveStart()
*/
public void setTrackIconActiveStart(@DrawableRes int iconResourceId) {
Drawable icon = null;
if (iconResourceId != 0) {
icon = AppCompatResources.getDrawable(getContext(), iconResourceId);
}
setTrackIconActiveStart(icon);
}
/**
* Gets the active track start icon shown, if present.
*
* @return Start icon shown for this active track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveStart
* @see #setTrackIconActiveStart(Drawable)
* @see #setTrackIconActiveStart(int)
*/
@Nullable
public Drawable getTrackIconActiveStart() {
return trackIconActiveStart;
}
/**
* Sets the active track end icon.
*
* @param icon Drawable to use for the active track's end icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveEnd
* @see #setTrackIconActiveEnd(int)
* @see #getTrackIconActiveEnd()
*/
public void setTrackIconActiveEnd(@Nullable Drawable icon) {
if (icon == trackIconActiveEnd) {
return;
}
trackIconActiveEnd = icon;
trackIconActiveEndMutated = false;
updateTrackIconActiveEnd();
invalidate();
}
private void updateTrackIconActiveEnd() {
if (trackIconActiveEnd != null) {
if (!trackIconActiveEndMutated && trackIconActiveColor != null) {
trackIconActiveEnd = DrawableCompat.wrap(trackIconActiveEnd).mutate();
trackIconActiveEndMutated = true;
}
if (trackIconActiveEndMutated) {
trackIconActiveEnd.setTintList(trackIconActiveColor);
}
}
}
/**
* Sets the active track end icon.
*
* @param iconResourceId Drawable resource ID to use for the active track's end icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveEnd
* @see #setTrackIconActiveEnd(Drawable)
* @see #getTrackIconActiveEnd()
*/
public void setTrackIconActiveEnd(@DrawableRes int iconResourceId) {
Drawable icon = null;
if (iconResourceId != 0) {
icon = AppCompatResources.getDrawable(getContext(), iconResourceId);
}
setTrackIconActiveEnd(icon);
}
/**
* Gets the active track end icon shown, if present.
*
* @return End icon shown for this active track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveEnd
* @see #setTrackIconActiveEnd(Drawable)
* @see #setTrackIconActiveEnd(int)
*/
@Nullable
public Drawable getTrackIconActiveEnd() {
return trackIconActiveEnd;
}
/**
* Sets the track icons size.
*
* @param size size to use for the track icons.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconSize
* @see #getTrackIconSize()
*/
public void setTrackIconSize(@Px int size) {
if (this.trackIconSize == size) {
return;
}
this.trackIconSize = size;
invalidate();
}
/**
* Gets the track icons size shown, if present.
*
* @return Size of the icons shown for this track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconSize
* @see #setTrackIconSize(int)
*/
public int getTrackIconSize() {
return trackIconSize;
}
/**
* Sets the active track icon color.
*
* @param color color to use for the active track's icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveColor
* @see #getTrackIconActiveColor()
*/
public void setTrackIconActiveColor(@Nullable ColorStateList color) {
if (color == trackIconActiveColor) {
return;
}
trackIconActiveColor = color;
updateTrackIconActiveStart();
updateTrackIconActiveEnd();
invalidate();
}
/**
* Gets the active track icon color shown, if present.
*
* @return Color of the icon shown for this active track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconActiveColor
* @see #setTrackIconActiveColor(ColorStateList)
*/
@Nullable
public ColorStateList getTrackIconActiveColor() {
return trackIconActiveColor;
}
/**
* Sets the inactive track start icon.
*
* @param icon Drawable to use for the inactive track's start icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveStart
* @see #setTrackIconInactiveStart(int)
* @see #getTrackIconInactiveStart()
*/
public void setTrackIconInactiveStart(@Nullable Drawable icon) {
if (icon == trackIconInactiveStart) {
return;
}
trackIconInactiveStart = icon;
trackIconInactiveStartMutated = false;
updateTrackIconInactiveStart();
invalidate();
}
private void updateTrackIconInactiveStart() {
if (trackIconInactiveStart != null) {
if (!trackIconInactiveStartMutated && trackIconInactiveColor != null) {
trackIconInactiveStart = DrawableCompat.wrap(trackIconInactiveStart).mutate();
trackIconInactiveStartMutated = true;
}
if (trackIconInactiveStartMutated) {
trackIconInactiveStart.setTintList(trackIconInactiveColor);
}
}
}
/**
* Sets the inactive track start icon.
*
* @param iconResourceId Drawable resource ID to use for the inactive track's start icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveStart
* @see #setTrackIconInactiveStart(Drawable)
* @see #getTrackIconInactiveStart()
*/
public void setTrackIconInactiveStart(@DrawableRes int iconResourceId) {
Drawable icon = null;
if (iconResourceId != 0) {
icon = AppCompatResources.getDrawable(getContext(), iconResourceId);
}
setTrackIconInactiveStart(icon);
}
/**
* Gets the inactive track start icon shown, if present.
*
* @return Start icon shown for this inactive track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveStart
* @see #setTrackIconInactiveStart(Drawable)
* @see #setTrackIconInactiveStart(int)
*/
@Nullable
public Drawable getTrackIconInactiveStart() {
return trackIconInactiveStart;
}
/**
* Sets the inactive track end icon.
*
* @param icon Drawable to use for the inactive track's end icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveEnd
* @see #setTrackIconInactiveEnd(int)
* @see #getTrackIconInactiveEnd()
*/
public void setTrackIconInactiveEnd(@Nullable Drawable icon) {
if (icon == trackIconInactiveEnd) {
return;
}
trackIconInactiveEnd = icon;
trackIconInactiveEndMutated = false;
updateTrackIconInactiveEnd();
invalidate();
}
private void updateTrackIconInactiveEnd() {
if (trackIconInactiveEnd != null) {
if (!trackIconInactiveEndMutated && trackIconInactiveColor != null) {
trackIconInactiveEnd = DrawableCompat.wrap(trackIconInactiveEnd).mutate();
trackIconInactiveEndMutated = true;
}
if (trackIconInactiveEndMutated) {
trackIconInactiveEnd.setTintList(trackIconInactiveColor);
}
}
}
/**
* Sets the inactive track end icon.
*
* @param iconResourceId Drawable resource ID to use for the inactive track's end icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveEnd
* @see #setTrackIconInactiveEnd(Drawable)
* @see #getTrackIconInactiveEnd()
*/
public void setTrackIconInactiveEnd(@DrawableRes int iconResourceId) {
Drawable icon = null;
if (iconResourceId != 0) {
icon = AppCompatResources.getDrawable(getContext(), iconResourceId);
}
setTrackIconInactiveEnd(icon);
}
/**
* Gets the inactive track end icon shown, if present.
*
* @return End icon shown for this inactive track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveEnd
* @see #setTrackIconInactiveEnd(Drawable)
* @see #setTrackIconInactiveEnd(int)
*/
@Nullable
public Drawable getTrackIconInactiveEnd() {
return trackIconInactiveEnd;
}
/**
* Sets the inactive track icon color.
*
* @param color color to use for the inactive track's icon.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveColor
* @see #getTrackIconInactiveColor()
*/
public void setTrackIconInactiveColor(@Nullable ColorStateList color) {
if (color == trackIconInactiveColor) {
return;
}
trackIconInactiveColor = color;
updateTrackIconInactiveStart();
updateTrackIconInactiveEnd();
invalidate();
}
/**
* Gets the inactive track icon color shown, if present.
*
* @return Color of the icon shown for this inactive track, if present.
* @attr ref com.google.android.material.R.styleable#Slider_trackIconInactiveColor
* @see #setTrackIconInactiveColor(ColorStateList)
*/
@Nullable
public ColorStateList getTrackIconInactiveColor() {
return trackIconInactiveColor;
}
@Override
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
super.onVisibilityChanged(changedView, visibility);
// When the visibility is set to VISIBLE, onDraw() is called again which adds or removes labels
// according to the setting.
if (visibility != VISIBLE) {
final ViewOverlay contentViewOverlay = getContentViewOverlay();
if (contentViewOverlay == null) {
return;
}
for (TooltipDrawable label : labels) {
contentViewOverlay.remove(label);
}
}
}
@Nullable
private ViewOverlay getContentViewOverlay() {
final View contentView = ViewUtils.getContentView(this);
return contentView == null ? null : contentView.getOverlay();
}
@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);
}
public void setOrientation(@Orientation int orientation) {
if (this.widgetOrientation == orientation) {
return;
}
this.widgetOrientation = orientation;
updateWidgetLayout(true);
}
/**
* Sets the slider to be in centered configuration, meaning the starting value is positioned in
* the middle of the slider.
*
* @param isCentered boolean to use for the slider's centered configuration.
* @attr ref com.google.android.material.R.styleable#Slider_centered
* @see #isCentered()
*/
public void setCentered(boolean isCentered) {
if (this.centered == isCentered) {
return;
}
this.centered = isCentered;
// if centered, the default value is at the center
if (isCentered) {
setValues((valueFrom + valueTo) / 2f);
} else {
setValues(valueFrom);
}
updateWidgetLayout(true);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// Update factoring in the visibility of all ancestors.
thisAndAncestorsVisible = isShown();
getViewTreeObserver().addOnScrollChangedListener(onScrollChangedListener);
getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener);
// 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);
}
labelsAreAnimatedIn = false;
for (TooltipDrawable label : labels) {
detachLabelFromContentView(label);
}
getViewTreeObserver().removeOnScrollChangedListener(onScrollChangedListener);
getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener);
super.onDetachedFromWindow();
}
private void detachLabelFromContentView(TooltipDrawable label) {
final View contentView = ViewUtils.getContentView(this);
if (contentView == null) {
return;
}
contentView.getOverlay().remove(label);
label.detachView(contentView);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int labelSize = 0;
if (labelBehavior == LABEL_WITHIN_BOUNDS || shouldAlwaysShowLabel()) {
labelSize = labels.get(0).getIntrinsicHeight();
}
int spec = MeasureSpec.makeMeasureSpec(widgetThickness + labelSize, MeasureSpec.EXACTLY);
if (isVertical()) {
super.onMeasure(spec, heightMeasureSpec);
} else {
super.onMeasure(widthMeasureSpec, spec);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
updateTrackWidth(isVertical() ? h : w);
updateHaloHotspot();
}
private void updateTicksCoordinates() {
validateConfigurationIfDirty();
// Continuous mode.
if (stepSize <= 0.0f) {
updateTicksCoordinates(continuousModeTickCount);
return;
}
final int tickCount;
switch (tickVisibilityMode) {
case TICK_VISIBILITY_AUTO_LIMIT:
tickCount = min(getDesiredTickCount(), getMaxTickCount());
break;
case TICK_VISIBILITY_AUTO_HIDE:
int desiredTickCount = getDesiredTickCount();
tickCount = desiredTickCount <= getMaxTickCount() ? desiredTickCount : 0;
break;
case TICK_VISIBILITY_HIDDEN:
tickCount = 0;
break;
default:
throw new IllegalStateException("Unexpected tickVisibilityMode: " + tickVisibilityMode);
}
updateTicksCoordinates(tickCount);
}
private void updateTicksCoordinates(int tickCount) {
if (tickCount == 0) {
ticksCoordinates = null;
return;
}
if (ticksCoordinates == null || ticksCoordinates.length != tickCount * 2) {
ticksCoordinates = new float[tickCount * 2];
}
float interval = trackWidth / (float) (tickCount - 1);
float trackCenterY = calculateTrackCenter();
for (int i = 0; i < tickCount * 2; i += 2) {
ticksCoordinates[i] = trackSidePadding + i / 2f * interval;
ticksCoordinates[i + 1] = trackCenterY;
}
if (isVertical()) {
rotationMatrix.mapPoints(ticksCoordinates);
}
}
private int getDesiredTickCount() {
return (int) ((valueTo - valueFrom) / stepSize + 1);
}
private int getMaxTickCount() {
return trackWidth / minTickSpacing + 1;
}
private void updateTrackWidth(int width) {
// Update the visible track width.
trackWidth = max(width - trackSidePadding * 2, 0);
// Update the visible tick coordinates.
updateTicksCoordinates();
}
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) {
float x = normalizeValue(values.get(focusedThumbIdx)) * trackWidth + trackSidePadding;
int y = calculateTrackCenter();
float[] haloBounds = {x - haloRadius, y - haloRadius, x + haloRadius, y + haloRadius};
if (isVertical()) {
rotationMatrix.mapPoints(haloBounds);
}
background.setHotspotBounds(
(int) haloBounds[0], (int) haloBounds[1], (int) haloBounds[2], (int) haloBounds[3]);
}
}
}
private int calculateTrackCenter() {
return widgetThickness / 2
+ (labelBehavior == LABEL_WITHIN_BOUNDS || shouldAlwaysShowLabel()
? labels.get(0).getIntrinsicHeight()
: 0);
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
if (dirtyConfig) {
validateConfigurationIfDirty();
// Update the visible tick coordinates.
updateTicksCoordinates();
}
super.onDraw(canvas);
int yCenter = calculateTrackCenter();
drawInactiveTracks(canvas, trackWidth, yCenter);
drawActiveTracks(canvas, trackWidth, yCenter);
if (isRtl() || isVertical()) {
drawTrackIcons(canvas, activeTrackRect, inactiveTrackLeftRect);
} else {
drawTrackIcons(canvas, activeTrackRect, inactiveTrackRightRect);
}
maybeDrawTicks(canvas);
maybeDrawStopIndicator(canvas, yCenter);
if ((thumbIsPressed || isFocused()) && isEnabled()) {
maybeDrawCompatHalo(canvas, trackWidth, yCenter);
}
updateLabels();
drawThumbs(canvas, trackWidth, yCenter);
}
/**
* 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 min = values.get(0);
float max = values.get(values.size() - 1);
float left = normalizeValue(values.size() == 1 ? valueFrom : min);
float right = normalizeValue(max);
// When centered, the active range is bound by the center.
if (isCentered()) {
left = min(.5f, right);
right = max(.5f, right);
}
// In RTL we draw things in reverse, so swap the left and right range values.
return !isCentered() && (isRtl() || isVertical())
? new float[] {right, left}
: new float[] {left, right};
}
private void drawInactiveTracks(@NonNull Canvas canvas, int width, int yCenter) {
float[] activeRange = getActiveRange();
float top = yCenter - trackThickness / 2f;
float bottom = yCenter + trackThickness / 2f;
int leftGapSize;
if (isCentered() && activeRange[0] == 0.5f) {
leftGapSize = thumbTrackGapSize;
} else {
leftGapSize = calculateThumbTrackGapSize(isRtl() || isVertical() ? values.size() - 1 : 0);
}
drawInactiveTrackSection(
trackSidePadding - getTrackCornerSize(),
trackSidePadding + activeRange[0] * width - leftGapSize,
top,
bottom,
canvas,
inactiveTrackLeftRect,
FullCornerDirection.LEFT,
leftGapSize);
int rightGapSize;
if (isCentered() && activeRange[1] == 0.5f) {
rightGapSize = thumbTrackGapSize;
} else {
rightGapSize = calculateThumbTrackGapSize(isRtl() || isVertical() ? 0 : values.size() - 1);
}
drawInactiveTrackSection(
trackSidePadding + activeRange[1] * width + rightGapSize,
trackSidePadding + width + getTrackCornerSize(),
top,
bottom,
canvas,
inactiveTrackRightRect,
FullCornerDirection.RIGHT,
rightGapSize);
}
private void drawInactiveTrackSection(
float from,
float to,
float top,
float bottom,
@NonNull Canvas canvas,
RectF rect,
FullCornerDirection direction,
int gapSize) {
if (to - from > getTrackCornerSize() - gapSize) {
rect.set(from, top, to, bottom);
} else {
rect.setEmpty();
}
updateTrack(canvas, inactiveTrackPaint, rect, getTrackCornerSize(), direction);
}
/**
* 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() || isVertical()) {
return 1 - normalized;
}
return normalized;
}
private void drawActiveTracks(@NonNull Canvas canvas, int width, int yCenter) {
float[] activeRange = getActiveRange();
float right = trackSidePadding + activeRange[1] * width;
float left = trackSidePadding + activeRange[0] * width;
if (left >= right) {
activeTrackRect.setEmpty();
return;
}
FullCornerDirection direction = FullCornerDirection.NONE;
if (values.size() == 1 && !isCentered()) { // Only 1 thumb
direction = isRtl() || isVertical() ? FullCornerDirection.RIGHT : FullCornerDirection.LEFT;
}
for (int i = 0; i < values.size(); i++) {
if (values.size() > 1) {
if (i > 0) {
left = valueToX(values.get(i - 1));
}
right = valueToX(values.get(i));
if (isRtl() || isVertical()) { // Swap left right
float temp = left;
left = right;
right = temp;
}
}
int trackCornerSize = getTrackCornerSize();
switch (direction) {
case NONE:
if (i > 0) {
left += calculateThumbTrackGapSize(i - 1);
right -= calculateThumbTrackGapSize(i);
} else if (activeRange[1] == .5f) { // centered, active track ends at the center
left += calculateThumbTrackGapSize(i);
} else if (activeRange[0] == .5f) { // centered, active track starts at the center
right -= calculateThumbTrackGapSize(i);
}
break;
case LEFT:
left -= trackCornerSize;
right -= calculateThumbTrackGapSize(i);
break;
case RIGHT:
left += calculateThumbTrackGapSize(i);
right += trackCornerSize;
break;
default:
// fall through
}
// Nothing to draw if left is bigger than right.
if (left >= right) {
activeTrackRect.setEmpty();
continue;
}
activeTrackRect.set(
left, yCenter - trackThickness / 2f, right, yCenter + trackThickness / 2f);
updateTrack(canvas, activeTrackPaint, activeTrackRect, trackCornerSize, direction);
}
}
private float calculateStartTrackCornerSize(float trackCornerSize) {
if (values.isEmpty() || !hasGapBetweenThumbAndTrack()) {
return trackCornerSize;
}
int firstIdx = isRtl() || isVertical() ? values.size() - 1 : 0;
float currentX = valueToX(values.get(firstIdx)) - trackSidePadding;
if (currentX < trackCornerSize) {
return max(currentX, trackInsideCornerSize);
}
return trackCornerSize;
}
private float calculateEndTrackCornerSize(float trackCornerSize) {
if (values.isEmpty() || !hasGapBetweenThumbAndTrack()) {
return trackCornerSize;
}
int lastIdx = isRtl() || isVertical() ? 0 : values.size() - 1;
float currentX = valueToX(values.get(lastIdx)) - trackSidePadding;
if (currentX > trackWidth - trackCornerSize) {
return max(trackWidth - currentX, trackInsideCornerSize);
}
return trackCornerSize;
}
private void drawTrackIcons(
@NonNull Canvas canvas,
@NonNull RectF activeTrackBounds,
@NonNull RectF inactiveTrackBounds) {
if (!hasTrackIcons()) {
return;
}
if (values.size() > 1) {
Log.w(TAG, "Track icons can only be used when only 1 thumb is present.");
}
// draw track start icons
calculateBoundsAndDrawTrackIcon(canvas, activeTrackBounds, trackIconActiveStart, true);
calculateBoundsAndDrawTrackIcon(canvas, inactiveTrackBounds, trackIconInactiveStart, true);
// draw track end icons
calculateBoundsAndDrawTrackIcon(canvas, activeTrackBounds, trackIconActiveEnd, false);
calculateBoundsAndDrawTrackIcon(canvas, inactiveTrackBounds, trackIconInactiveEnd, false);
}
private boolean hasTrackIcons() {
return trackIconActiveStart != null
|| trackIconActiveEnd != null
|| trackIconInactiveStart != null
|| trackIconInactiveEnd != null;
}
private void calculateBoundsAndDrawTrackIcon(
@NonNull Canvas canvas,
@NonNull RectF trackBounds,
@Nullable Drawable icon,
boolean isStart) {
if (icon != null) {
calculateTrackIconBounds(trackBounds, iconRectF, trackIconSize, trackIconPadding, isStart);
if (!iconRectF.isEmpty()) {
drawTrackIcon(canvas, iconRectF, icon);
}
}
}
private void drawTrackIcon(
@NonNull Canvas canvas, @NonNull RectF iconBounds, @NonNull Drawable icon) {
if (isVertical()) {
rotationMatrix.mapRect(iconBounds);
}
iconBounds.round(iconRect);
icon.setBounds(iconRect);
icon.draw(canvas);
}
private void calculateTrackIconBounds(
@NonNull RectF trackBounds,
@NonNull RectF iconBounds,
@Px int iconSize,
@Px int iconPadding,
boolean isStart) {
if (trackBounds.right - trackBounds.left >= iconSize + 2 * iconPadding) {
float iconLeft =
(isStart ^ (isRtl() || isVertical()))
? trackBounds.left + iconPadding
: trackBounds.right - iconPadding - iconSize;
float iconTop = calculateTrackCenter() - iconSize / 2f;
float iconRight = iconLeft + iconSize;
float iconBottom = iconTop + iconSize;
iconBounds.set(iconLeft, iconTop, iconRight, iconBottom);
} else {
// not enough space to draw icon
iconBounds.setEmpty();
}
}
private boolean hasGapBetweenThumbAndTrack() {
return thumbTrackGapSize > 0;
}
private int calculateThumbTrackGapSize(int index) {
if (thumbIsPressed
&& index == activeThumbIdx
&& customThumbDrawable == null
&& customThumbDrawablesForValues.isEmpty()) {
int activeThumbWidth = Math.round(thumbWidth * THUMB_WIDTH_PRESSED_RATIO);
int delta = thumbWidth - activeThumbWidth;
return thumbTrackGapSize - delta / 2;
}
return thumbTrackGapSize;
}
// The direction where the track has full corners.
private enum FullCornerDirection {
BOTH,
LEFT,
RIGHT,
NONE
}
private void updateTrack(
Canvas canvas, Paint paint, RectF bounds, float cornerSize, FullCornerDirection direction) {
if (bounds.isEmpty()) {
return;
}
float leftCornerSize = calculateStartTrackCornerSize(cornerSize);
float rightCornerSize = calculateEndTrackCornerSize(cornerSize);
switch (direction) {
case BOTH:
break;
case LEFT:
rightCornerSize = trackInsideCornerSize;
break;
case RIGHT:
leftCornerSize = trackInsideCornerSize;
break;
case NONE:
leftCornerSize = trackInsideCornerSize;
rightCornerSize = trackInsideCornerSize;
break;
}
paint.setStyle(Style.FILL);
paint.setStrokeCap(Cap.BUTT);
// TODO(b/373654533): activate anti-aliasing for legacy Slider
if (hasGapBetweenThumbAndTrack()) {
paint.setAntiAlias(true);
}
RectF rotated = new RectF(bounds);
if (isVertical()) {
rotationMatrix.mapRect(rotated);
}
// Draws track path with rounded corners.
trackPath.reset();
if (bounds.width() >= leftCornerSize + rightCornerSize) {
// Fills one rounded rectangle.
trackPath.addRoundRect(
rotated, getCornerRadii(leftCornerSize, rightCornerSize), Direction.CW);
canvas.drawPath(trackPath, paint);
} else {
// Clips the canvas and draws the fully rounded track.
float minCornerSize = min(leftCornerSize, rightCornerSize);
float maxCornerSize = max(leftCornerSize, rightCornerSize);
canvas.save();
// Clips the canvas using the current bounds with the smaller corner size.
trackPath.addRoundRect(rotated, minCornerSize, minCornerSize, Direction.CW);
canvas.clipPath(trackPath);
// Then draws a rectangle with the minimum width for full corners.
switch (direction) {
case LEFT:
cornerRect.set(bounds.left, bounds.top, bounds.left + 2 * maxCornerSize, bounds.bottom);
break;
case RIGHT:
cornerRect.set(bounds.right - 2 * maxCornerSize, bounds.top, bounds.right, bounds.bottom);
break;
default:
cornerRect.set(
bounds.centerX() - maxCornerSize,
bounds.top,
bounds.centerX() + maxCornerSize,
bounds.bottom);
}
if (isVertical()) {
rotationMatrix.mapRect(cornerRect);
}
canvas.drawRoundRect(cornerRect, maxCornerSize, maxCornerSize, paint);
canvas.restore();
}
}
private float[] getCornerRadii(float leftSide, float rightSide) {
if (isVertical()) {
return new float[] {
leftSide, leftSide, leftSide, leftSide, rightSide, rightSide, rightSide, rightSide
};
} else {
return new float[] {
leftSide, leftSide,
rightSide, rightSide,
rightSide, rightSide,
leftSide, leftSide
};
}
}
private void maybeDrawTicks(@NonNull Canvas canvas) {
if (ticksCoordinates == null || ticksCoordinates.length == 0) {
return;
}
float[] activeRange = getActiveRange();
// Calculate the index of the left tick of the active track.
final int leftActiveTickIndex =
(int) Math.ceil(activeRange[0] * (ticksCoordinates.length / 2f - 1));
// Calculate the index of the right tick of the active track.
final int rightActiveTickIndex =
(int) Math.floor(activeRange[1] * (ticksCoordinates.length / 2f - 1));
// Draw ticks on the left inactive track (if any).
if (leftActiveTickIndex > 0) {
drawTicks(0, leftActiveTickIndex * 2, canvas, inactiveTicksPaint);
}
// Draw ticks on the active track (if any).
if (leftActiveTickIndex <= rightActiveTickIndex) {
drawTicks(leftActiveTickIndex * 2, (rightActiveTickIndex + 1) * 2, canvas, activeTicksPaint);
}
// Draw ticks on the right inactive track (if any).
if ((rightActiveTickIndex + 1) * 2 < ticksCoordinates.length) {
drawTicks(
(rightActiveTickIndex + 1) * 2, ticksCoordinates.length, canvas, inactiveTicksPaint);
}
}
private void drawTicks(int from, int to, Canvas canvas, Paint paint) {
for (int i = from; i < to; i += 2) {
float coordinateToCheck = isVertical() ? ticksCoordinates[i + 1] : ticksCoordinates[i];
if (isOverlappingThumb(coordinateToCheck)
|| (isCentered() && isOverlappingCenterGap(coordinateToCheck))) {
continue;
}
canvas.drawPoint(ticksCoordinates[i], ticksCoordinates[i + 1], paint);
}
}
private boolean isOverlappingThumb(float tickCoordinate) {
for (int i = 0; i < values.size(); i++) {
float valueToX = valueToX(values.get(i));
float threshold = calculateThumbTrackGapSize(i) + thumbWidth / 2f;
if (tickCoordinate >= valueToX - threshold && tickCoordinate <= valueToX + threshold) {
return true;
}
}
return false;
}
private boolean isOverlappingCenterGap(float tickCoordinate) {
float trackCenter = (trackWidth + trackSidePadding * 2) / 2f;
return tickCoordinate >= trackCenter - thumbTrackGapSize
&& tickCoordinate <= trackCenter + thumbTrackGapSize;
}
private void maybeDrawStopIndicator(@NonNull Canvas canvas, int yCenter) {
if (trackStopIndicatorSize <= 0 || values.isEmpty()) {
return;
}
// Draw stop indicator at the end of the track.
if (values.get(values.size() - 1) < valueTo) {
drawStopIndicator(canvas, valueToX(valueTo), yCenter);
}
// Centered, multiple thumbs, inactive track may be visible at the start.
if (isCentered() || (values.size() > 1 && values.get(0) > valueFrom)) {
drawStopIndicator(canvas, valueToX(valueFrom), yCenter);
}
}
private void drawStopIndicator(@NonNull Canvas canvas, float x, float y) {
// Prevent drawing indicator on the thumbs.
for (int i = 0; i < values.size(); i++) {
float valueToX = valueToX(values.get(i));
float threshold = calculateThumbTrackGapSize(i) + thumbWidth / 2f;
if (x >= valueToX - threshold && x <= valueToX + threshold) {
return;
}
}
if (isVertical()) {
canvas.drawPoint(y, x, stopIndicatorPaint);
} else {
canvas.drawPoint(x, y, stopIndicatorPaint);
}
}
private void drawThumbs(@NonNull Canvas canvas, int width, int yCenter) {
for (int i = 0; i < values.size(); i++) {
float value = values.get(i);
if (customThumbDrawable != null) {
drawThumbDrawable(canvas, width, yCenter, value, customThumbDrawable);
} else if (i < customThumbDrawablesForValues.size()) {
drawThumbDrawable(canvas, width, yCenter, value, customThumbDrawablesForValues.get(i));
} else {
// Clear out the track behind the thumb if we're in a disabled state since the thumb is
// transparent.
if (!isEnabled()) {
canvas.drawCircle(
trackSidePadding + normalizeValue(value) * width,
yCenter,
getThumbRadius(),
thumbPaint);
}
drawThumbDrawable(canvas, width, yCenter, value, defaultThumbDrawables.get(i));
}
}
}
private void drawThumbDrawable(
@NonNull Canvas canvas, int width, int top, float value, @NonNull Drawable thumbDrawable) {
canvas.save();
if (isVertical()) {
canvas.concat(rotationMatrix);
}
canvas.translate(
trackSidePadding
+ (int) (normalizeValue(value) * width)
- (thumbDrawable.getBounds().width() / 2f),
top - (thumbDrawable.getBounds().height() / 2f));
thumbDrawable.draw(canvas);
canvas.restore();
}
private void maybeDrawCompatHalo(@NonNull Canvas canvas, int width, int top) {
// Only draw the halo for devices that aren't using the ripple.
if (shouldDrawCompatHalo()) {
float centerX = trackSidePadding + normalizeValue(values.get(focusedThumbIdx)) * width;
float[] bounds = {centerX, top};
if (isVertical()) {
rotationMatrix.mapPoints(bounds);
}
if (VERSION.SDK_INT < VERSION_CODES.P) {
// In this case we can clip the rect to allow drawing outside the bounds.
canvas.clipRect(
bounds[0] - haloRadius,
bounds[1] - haloRadius,
bounds[0] + haloRadius,
bounds[1] + haloRadius,
Op.UNION);
}
canvas.drawCircle(bounds[0], bounds[1], haloRadius, haloPaint);
}
}
private boolean shouldDrawCompatHalo() {
return forceDrawCompatHalo || !(getBackground() instanceof RippleDrawable);
}
@Override
public void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
viewRect.left = 0;
viewRect.top = 0;
viewRect.right = right - left;
viewRect.bottom = bottom - top;
if (!exclusionRects.contains(viewRect)) {
exclusionRects.add(viewRect);
}
// Make sure that the slider takes precedence over back navigation gestures.
ViewCompat.setSystemGestureExclusionRects(this, exclusionRects);
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
if (!isEnabled()) {
return false;
}
float eventCoordinateAxis1 = isVertical() ? event.getY() : event.getX();
float eventCoordinateAxis2 = isVertical() ? event.getX() : event.getY();
touchPosition = (eventCoordinateAxis1 - trackSidePadding) / trackWidth;
touchPosition = max(0, touchPosition);
touchPosition = min(1, touchPosition);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
touchDownAxis1 = eventCoordinateAxis1;
touchDownAxis2 = eventCoordinateAxis2;
previousDownTouchEventValues.clear();
previousDownTouchEventValues = getValues();
// If we're inside a vertical scrolling container,
// we should start dragging in ACTION_MOVE
if (!isVertical() && isPotentialVerticalScroll(event)) {
break;
}
// If we're inside a horizontal scrolling container,
// we should start dragging in ACTION_MOVE
if (isVertical() && isPotentialHorizontalScroll(event)) {
break;
}
getParent().requestDisallowInterceptTouchEvent(true);
if (!pickActiveThumb()) {
// Couldn't determine the active thumb yet.
break;
}
requestFocus();
thumbIsPressed = true;
updateThumbWidthWhenPressed();
onStartTrackingTouch();
snapTouchPosition();
updateHaloHotspot();
invalidate();
break;
case MotionEvent.ACTION_MOVE:
if (!thumbIsPressed) {
// Check if we're trying to scroll vertically instead of dragging this Slider
if (!isVertical()
&& isPotentialVerticalScroll(event)
&& abs(eventCoordinateAxis1 - touchDownAxis1) < scaledTouchSlop) {
return false;
}
// Check if we're trying to scroll horizontally instead of dragging this Slider
if (isVertical()
&& isPotentialHorizontalScroll(event)
&& abs(eventCoordinateAxis2 - touchDownAxis2) < scaledTouchSlop * TOUCH_SLOP_RATIO) {
return false;
}
getParent().requestDisallowInterceptTouchEvent(true);
if (!pickActiveThumb()) {
// Couldn't determine the active thumb yet.
break;
}
thumbIsPressed = true;
updateThumbWidthWhenPressed();
onStartTrackingTouch();
}
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) {
if (pickActiveThumb()) {
onStartTrackingTouch();
}
}
if (activeThumbIdx != -1) {
snapTouchPosition();
updateHaloHotspot();
resetThumbWidth();
activeThumbIdx = -1;
onStopTrackingTouch();
}
invalidate();
break;
case MotionEvent.ACTION_CANCEL:
thumbIsPressed = false;
// Make sure that we reset the state of the slider if a cancel event happens.
snapThumbToPreviousDownTouchEventValue();
updateHaloHotspot();
resetThumbWidth();
activeThumbIdx = -1;
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;
}
private void updateThumbWidthWhenPressed() {
// Update default thumb width when pressed.
if (hasGapBetweenThumbAndTrack()
&& customThumbDrawable == null
&& customThumbDrawablesForValues.isEmpty()) {
defaultThumbWidth = thumbWidth;
defaultThumbTrackGapSize = thumbTrackGapSize;
int pressedThumbWidth = Math.round(thumbWidth * THUMB_WIDTH_PRESSED_RATIO);
// Only the currently pressed thumb should change width.
setThumbWidth(pressedThumbWidth, /* thumbIndex= */ activeThumbIdx);
}
}
private void resetThumbWidth() {
// Reset the default thumb width.
if (hasGapBetweenThumbAndTrack() && defaultThumbWidth != -1 && defaultThumbTrackGapSize != -1) {
// Only the currently pressed thumb should change width.
setThumbWidth(defaultThumbWidth, /* thumbIndex= */ activeThumbIdx);
}
}
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) > 0) {
break;
}
boolean movingForward =
(isRtl() || isVertical()) ? (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() || isVertical()) {
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);
}
@CanIgnoreReturnValue
private boolean snapThumbToValue(int idx, float value) {
focusedThumbIdx = idx;
// 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);
dispatchOnChangedFromUser(idx);
return true;
}
private void snapThumbToPreviousDownTouchEventValue() {
if (activeThumbIdx != -1 && !previousDownTouchEventValues.isEmpty()) {
for (int i = 0; i < values.size(); i++) {
if (i == activeThumbIdx) {
snapThumbToValue(i, previousDownTouchEventValues.get(i));
break;
}
}
}
}
/** 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 = getMinSeparation();
minSeparation = separationUnit == UNIT_PX ? dimenToValue(minSeparation) : minSeparation;
if (isRtl() || isVertical()) {
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;
dirtyConfig = true;
postInvalidate();
}
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() || isVertical()) {
position = 1 - position;
}
return (float) (position * (valueTo - valueFrom) + valueFrom);
}
private float valueToX(float value) {
return normalizeValue(value) * trackWidth + trackSidePadding;
}
/**
* A helper method to get the current animated value of a {@link ValueAnimator}. If the target
* animator is null or not running, return the default value provided.
*/
private static float getAnimatorCurrentValueOrDefault(
ValueAnimator animator, float defaultValue) {
// If the in animation is interrupting the out animation, attempt to smoothly interrupt by
// getting the current value of the out animator.
if (animator != null && animator.isRunning()) {
float value = (float) animator.getAnimatedValue();
animator.cancel();
return value;
}
return defaultValue;
}
/**
* Create an animator that shows or hides all slider labels.
*
* @param enter True if this animator should show (reveal) labels. False if this animator should
* hide labels.
* @return A value animator that, when run, will animate all labels in or out using {@link
* TooltipDrawable#setRevealFraction(float)}.
*/
private ValueAnimator createLabelAnimator(boolean enter) {
float startFraction = enter ? 0F : 1F;
// Update the start fraction to the current animated value of the label, if any.
startFraction =
getAnimatorCurrentValueOrDefault(
enter ? labelsOutAnimator : labelsInAnimator, startFraction);
float endFraction = enter ? 1F : 0F;
ValueAnimator animator = ValueAnimator.ofFloat(startFraction, endFraction);
int duration;
TimeInterpolator interpolator;
if (enter) {
duration =
MotionUtils.resolveThemeDuration(
getContext(),
LABEL_ANIMATION_ENTER_DURATION_ATTR,
DEFAULT_LABEL_ANIMATION_ENTER_DURATION);
interpolator =
MotionUtils.resolveThemeInterpolator(
getContext(),
LABEL_ANIMATION_ENTER_EASING_ATTR,
AnimationUtils.DECELERATE_INTERPOLATOR);
} else {
duration =
MotionUtils.resolveThemeDuration(
getContext(),
LABEL_ANIMATION_EXIT_DURATION_ATTR,
DEFAULT_LABEL_ANIMATION_EXIT_DURATION);
interpolator =
MotionUtils.resolveThemeInterpolator(
getContext(),
LABEL_ANIMATION_EXIT_EASING_ATTR,
AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR);
}
animator.setDuration(duration);
animator.setInterpolator(interpolator);
animator.addUpdateListener(
animation -> {
float fraction = (float) animation.getAnimatedValue();
for (TooltipDrawable label : labels) {
label.setRevealFraction(fraction);
}
// Ensure the labels are redrawn even if the slider has stopped moving
postInvalidateOnAnimation();
});
return animator;
}
private void updateLabels() {
updateLabelPivots();
switch (labelBehavior) {
case LABEL_GONE:
ensureLabelsRemoved();
break;
case LABEL_VISIBLE:
if (isEnabled() && isSliderVisibleOnScreen()) {
ensureLabelsAdded(/* showLabelOnAllThumbs= */ true);
} else {
ensureLabelsRemoved();
}
break;
case LABEL_FLOATING:
case LABEL_WITHIN_BOUNDS:
if (activeThumbIdx != -1 && isEnabled()) {
ensureLabelsAdded(/* showLabelOnAllThumbs= */ false);
} else {
ensureLabelsRemoved();
}
break;
default:
throw new IllegalArgumentException("Unexpected labelBehavior: " + labelBehavior);
}
}
private void updateLabelPivots() {
// Set the pivot point so that the label pops up in the direction from the thumb.
final float labelPivotX;
final float labelPivotY;
final boolean isVertical = isVertical();
final boolean isRtl = isRtl();
if (isVertical && isRtl) {
labelPivotX = RIGHT_LABEL_PIVOT_X;
labelPivotY = RIGHT_LABEL_PIVOT_Y;
} else if (isVertical) {
labelPivotX = LEFT_LABEL_PIVOT_X;
labelPivotY = LEFT_LABEL_PIVOT_Y;
} else {
labelPivotX = TOP_LABEL_PIVOT_X;
labelPivotY = TOP_LABEL_PIVOT_Y;
}
for (TooltipDrawable label : labels) {
label.setPivots(labelPivotX, labelPivotY);
}
}
private boolean isSliderVisibleOnScreen() {
final Rect contentViewBounds = new Rect();
ViewUtils.getContentView(this).getHitRect(contentViewBounds);
return getLocalVisibleRect(contentViewBounds) && isThisAndAncestorsVisible();
}
private boolean isThisAndAncestorsVisible() {
// onVisibilityAggregated is only available on N+ devices, so on pre-N devices we check if this
// view and its ancestors are visible each time, in case one of the visibilities has changed.
return (VERSION.SDK_INT >= VERSION_CODES.N) ? thisAndAncestorsVisible : isShown();
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
// Setting visible to user to false prevents duplicate announcements by making only our virtual
// view accessible, not the parent container.
info.setVisibleToUser(false);
}
@Override
public void onVisibilityAggregated(boolean isVisible) {
super.onVisibilityAggregated(isVisible);
this.thisAndAncestorsVisible = isVisible;
}
private void ensureLabelsRemoved() {
// If the labels are animated in or in the process of animating in, create and start a new
// animator to animate out the labels and remove them once the animation ends.
if (labelsAreAnimatedIn) {
labelsAreAnimatedIn = false;
labelsOutAnimator = createLabelAnimator(false);
labelsInAnimator = null;
labelsOutAnimator.addListener(
new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
final ViewOverlay contentViewOverlay = getContentViewOverlay();
if (contentViewOverlay == null) {
return;
}
for (TooltipDrawable label : labels) {
contentViewOverlay.remove(label);
}
}
});
labelsOutAnimator.start();
}
}
private void ensureLabelsAdded(boolean showLabelOnAllThumbs) {
// If the labels are not animating in, start an animator to show them. ensureLabelsAdded will
// be called multiple times by BaseSlider's draw method, making this check necessary to avoid
// creating and starting an animator for each draw call.
if (!labelsAreAnimatedIn) {
labelsAreAnimatedIn = true;
labelsInAnimator = createLabelAnimator(true);
labelsOutAnimator = null;
labelsInAnimator.start();
}
Iterator