/* * 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.math.MathUtils.clamp; import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT; 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.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.util.Collections.max; import static java.util.Collections.min; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; 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.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.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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.ViewOverlayImpl; import com.google.android.material.internal.ViewUtils; import com.google.android.material.motion.MotionUtils; 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; 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();
}
}
/**
* 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) {
int thumbDiameter = thumbRadius * 2;
int originalWidth = drawable.getIntrinsicWidth();
int originalHeight = drawable.getIntrinsicHeight();
if (originalWidth == -1 && originalHeight == -1) {
drawable.setBounds(0, 0, thumbDiameter, thumbDiameter);
} else {
float scaleRatio = (float) thumbDiameter / 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 defaultThumbDrawable.getElevation();
}
/**
* Sets the elevation of the thumb.
*
* @see #getThumbElevation()
* @attr ref com.google.android.material.R.styleable#Slider_thumbElevation
*/
public void setThumbElevation(float elevation) {
defaultThumbDrawable.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. 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
*/
@Dimension
public int getThumbRadius() {
return thumbRadius;
}
/**
* 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) @Dimension int radius) {
if (radius == thumbRadius) {
return;
}
thumbRadius = radius;
defaultThumbDrawable.setShapeAppearanceModel(
ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, thumbRadius).build());
defaultThumbDrawable.setBounds(0, 0, thumbRadius * 2, thumbRadius * 2);
if (customThumbDrawable != null) {
adjustCustomThumbDrawableBounds(customThumbDrawable);
}
for (Drawable customDrawable : customThumbDrawablesForValues) {
adjustCustomThumbDrawableBounds(customDrawable);
}
updateWidgetLayout();
}
/**
* 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));
}
/**
* 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) {
defaultThumbDrawable.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 defaultThumbDrawable.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) {
defaultThumbDrawable.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 defaultThumbDrawable.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 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. */
@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();
updateWidgetLayout();
}
}
/**
* Returns the radius of the active tick in pixels.
*
* @attr ref com.google.android.material.R.styleable#Slider_activeTickRadius
* @see #setTickActiveRadius(int)
*/
@Dimension()
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) @Dimension int tickActiveRadius) {
if (this.tickActiveRadius != tickActiveRadius) {
this.tickActiveRadius = tickActiveRadius;
activeTicksPaint.setStrokeWidth(tickActiveRadius * 2);
updateWidgetLayout();
}
}
/**
* Returns the radius of the inactive tick in pixels.
*
* @attr ref com.google.android.material.R.styleable#Slider_inactiveTickRadius
* @see #setTickInactiveRadius(int)
*/
@Dimension()
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) @Dimension int tickInactiveRadius) {
if (this.tickInactiveRadius != tickInactiveRadius) {
this.tickInactiveRadius = tickInactiveRadius;
inactiveTicksPaint.setStrokeWidth(tickInactiveRadius * 2);
updateWidgetLayout();
}
}
private void updateWidgetLayout() {
boolean sizeChanged = maybeIncreaseWidgetHeight();
boolean sidePaddingChanged = maybeIncreaseTrackSidePadding();
if (sizeChanged) {
requestLayout();
} else if (sidePaddingChanged) {
postInvalidate();
}
}
private boolean maybeIncreaseWidgetHeight() {
int topAndBottomPaddings = getPaddingTop() + getPaddingBottom();
int minHeightRequiredByTrack = trackHeight + topAndBottomPaddings;
int minHeightRequiredByThumb = thumbRadius * 2 + getPaddingTop() + getPaddingBottom();
int newWidgetHeight =
max(minWidgetHeight, max(minHeightRequiredByTrack, minHeightRequiredByThumb));
if (newWidgetHeight == widgetHeight) {
return false;
}
widgetHeight = newWidgetHeight;
return true;
}
/**
* 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 defaultThumbDrawable.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) {
if (thumbColor.equals(defaultThumbDrawable.getFillColor())) {
return;
}
defaultThumbDrawable.setFillColor(thumbColor);
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.
*
* @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
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) {
ViewOverlayImpl contentViewOverlay = ViewUtils.getContentViewOverlay(this);
if (contentViewOverlay == null) {
return;
}
for (TooltipDrawable label : labels) {
contentViewOverlay.remove(label);
}
}
}
@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);
}
labelsAreAnimatedIn = false;
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 || shouldAlwaysShowLabel()
? labels.get(0).getIntrinsicHeight()
: 0),
MeasureSpec.EXACTLY));
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
updateTrackWidth(w);
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 = 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 / 2f * interval;
ticksCoordinates[i + 1] = calculateTrackCenter();
}
}
private void updateTrackWidth(int width) {
// Update the visible track width.
trackWidth = max(width - trackSidePadding * 2, 0);
// Update the visible tick coordinates.
maybeCalculateTicksCoordinates();
}
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 = calculateTrackCenter();
DrawableCompat.setHotspotBounds(
background, x - haloRadius, y - haloRadius, x + haloRadius, y + haloRadius);
}
}
}
private int calculateTrackCenter() {
return widgetHeight / 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.
maybeCalculateTicksCoordinates();
}
super.onDraw(canvas);
int yCenter = calculateTrackCenter();
drawInactiveTrack(canvas, trackWidth, yCenter);
if (max(getValues()) > valueFrom) {
drawActiveTrack(canvas, trackWidth, yCenter);
}
maybeDrawTicks(canvas);
if ((thumbIsPressed || isFocused()) && isEnabled()) {
maybeDrawCompatHalo(canvas, trackWidth, yCenter);
}
// Draw labels if there is an active thumb or the labels are always visible.
if ((activeThumbIdx != -1 || shouldAlwaysShowLabel()) && isEnabled()) {
ensureLabelsAdded();
} else {
ensureLabelsRemoved();
}
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 max = max(getValues());
float min = 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 yCenter) {
float[] activeRange = getActiveRange();
float right = trackSidePadding + activeRange[1] * width;
if (right < trackSidePadding + width) {
canvas.drawLine(right, yCenter, trackSidePadding + width, yCenter, inactiveTrackPaint);
}
// Also draw inactive track to the left if there is any
float left = trackSidePadding + activeRange[0] * width;
if (left > trackSidePadding) {
canvas.drawLine(trackSidePadding, yCenter, left, yCenter, 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 yCenter) {
float[] activeRange = getActiveRange();
float right = trackSidePadding + activeRange[1] * width;
float left = trackSidePadding + activeRange[0] * width;
canvas.drawLine(left, yCenter, right, yCenter, 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 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 disable state since the thumb is
// transparent.
if (!isEnabled()) {
canvas.drawCircle(
trackSidePadding + normalizeValue(value) * width, yCenter, thumbRadius, thumbPaint);
}
drawThumbDrawable(canvas, width, yCenter, value, defaultThumbDrawable);
}
}
}
private void drawThumbDrawable(
@NonNull Canvas canvas, int width, int top, float value, @NonNull Drawable thumbDrawable) {
canvas.save();
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()) {
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 = max(0, touchPosition);
touchPosition = min(1, touchPosition);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
touchDownX = x;
// If we're inside a vertical scrolling container,
// we should start dragging in ACTION_MOVE
if (isInVerticalScrollingContainer()) {
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 vertically instead of dragging this Slider
if (isInVerticalScrollingContainer() && 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:
case MotionEvent.ACTION_CANCEL:
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();
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;
}
/**
* 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) {
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;
}
/** 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()) {
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()) {
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(
new AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator 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
ViewCompat.postInvalidateOnAnimation(BaseSlider.this);
}
});
return animator;
}
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);
ViewOverlayImpl contentViewOverlay = ViewUtils.getContentViewOverlay(BaseSlider.this);
for (TooltipDrawable label : labels) {
contentViewOverlay.remove(label);
}
}
});
labelsOutAnimator.start();
}
}
private void ensureLabelsAdded() {
if (labelBehavior == LABEL_GONE) {
// If the label shouldn't be drawn we can skip this.
return;
}
// 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