/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.material.slider; import com.google.android.material.R; import static androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT; import static androidx.core.math.MathUtils.clamp; import static com.google.android.material.slider.LabelFormatter.LABEL_FLOATING; import static com.google.android.material.slider.LabelFormatter.LABEL_GONE; import static com.google.android.material.slider.LabelFormatter.LABEL_WITHIN_BOUNDS; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import static java.lang.Float.compare; import static java.lang.Math.abs; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Paint.Cap; import android.graphics.Paint.Style; import android.graphics.PorterDuff.Mode; import android.graphics.PorterDuffXfermode; import android.graphics.Rect; import android.graphics.Region.Op; import android.graphics.drawable.Drawable; import android.graphics.drawable.RippleDrawable; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat; import androidx.appcompat.content.res.AppCompatResources; import android.util.AttributeSet; import android.util.Log; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewParent; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityManager; import android.widget.SeekBar; import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.Dimension; import androidx.annotation.IntDef; import androidx.annotation.IntRange; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.customview.widget.ExploreByTouchHelper; import com.google.android.material.drawable.DrawableUtils; import com.google.android.material.internal.DescendantOffsetUtils; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewOverlayImpl; import com.google.android.material.internal.ViewUtils; import com.google.android.material.resources.MaterialResources; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.MaterialShapeDrawable; import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.tooltip.TooltipDrawable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.math.BigDecimal; import java.math.MathContext; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List; /** * The slider can function either as a continuous slider, or as a discrete slider. The mode of * operation is controlled by the value of the step size. If the step size is set to 0, the slider * operates as a continuous slider where the slider's thumb can be moved to any position along the * horizontal line. If the step size is set to a number greater than 0, the slider operates as a * discrete slider where the slider's thumb will snap to the closest valid value. See {@link * #setStepSize(float)}. * *
The {@link LabelFormatter} interface defines a formatter to be used to render text within the * value indicator label on interaction. * *
{@link BasicLabelFormatter} is a simple implementation of the {@link LabelFormatter} that * displays the selected value using letters to indicate magnitude (e.g.: 1.5K, 3M, 12B, etc..). * *
With the default style {@link * com.google.android.material.R.style#Widget_MaterialComponents_Slider}, colorPrimary and * colorOnPrimary are used to customize the color of the slider when enabled, and colorOnSurface is * used when disabled. The following attributes are used to customize the slider's appearance * further: * *
The following XML attributes are used to set the slider's various parameters of operation: * *
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,
Float.toString(stepSize),
Float.toString(valueFrom),
Float.toString(valueTo)));
}
if (this.stepSize != stepSize) {
this.stepSize = stepSize;
dirtyConfig = true;
postInvalidate();
}
}
/** Returns the index of the currently focused thumb */
public int getFocusedThumbIndex() {
return focusedThumbIdx;
}
/** Sets the index of the currently focused thumb */
public void setFocusedThumbIndex(int index) {
if (index < 0 || index >= values.size()) {
throw new IllegalArgumentException("index out of range");
}
focusedThumbIdx = index;
accessibilityHelper.requestKeyboardFocusForVirtualView(focusedThumbIdx);
postInvalidate();
}
protected void setActiveThumbIndex(int index) {
activeThumbIdx = index;
}
/** Returns the index of the currently active thumb, or -1 if no thumb is active */
public int getActiveThumbIndex() {
return activeThumbIdx;
}
/**
* Registers a callback to be invoked when the slider changes.
*
* @param listener The callback to run when the slider changes
*/
public void addOnChangeListener(@Nullable L listener) {
changeListeners.add(listener);
}
/**
* Removes a callback for value changes from this slider.
*
* @param listener The callback that'll stop receive slider changes
*/
public void removeOnChangeListener(@NonNull L listener) {
changeListeners.remove(listener);
}
/** Removes all instances of attached to this slider */
public void clearOnChangeListeners() {
changeListeners.clear();
}
/**
* Registers a callback to be invoked when the slider touch event is being started or stopped
*
* @param listener The callback to run when the slider starts or stops being touched
*/
public void addOnSliderTouchListener(@NonNull T listener) {
touchListeners.add(listener);
}
/**
* Removes a callback to be invoked when the slider touch event is being started or stopped
*
* @param listener The callback that'll stop be notified when the slider is being touched
*/
public void removeOnSliderTouchListener(@NonNull T listener) {
touchListeners.remove(listener);
}
/** Removes all instances of touch listeners attached to this slider */
public void clearOnSliderTouchListeners() {
touchListeners.clear();
}
/**
* Returns {@code true} if the slider has a {@link LabelFormatter} attached, {@code false}
* otherwise.
*/
public boolean hasLabelFormatter() {
return formatter != null;
}
/**
* Registers a {@link LabelFormatter} to be used to format the value displayed in the bubble shown
* when the slider operates in discrete mode.
*
* @param formatter The {@link LabelFormatter} to use to format the bubble's text
*/
public void setLabelFormatter(@Nullable LabelFormatter formatter) {
this.formatter = formatter;
}
/**
* Returns the elevation of the thumb.
*
* @see #setThumbElevation(float)
* @see #setThumbElevationResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_thumbElevation
*/
public float getThumbElevation() {
return thumbDrawable.getElevation();
}
/**
* Sets the elevation of the thumb.
*
* @see #getThumbElevation()
* @attr ref com.google.android.material.R.styleable#Slider_thumbElevation
*/
public void setThumbElevation(float elevation) {
thumbDrawable.setElevation(elevation);
}
/**
* Sets the elevation of the thumb from a dimension resource.
*
* @see #getThumbElevation()
* @attr ref com.google.android.material.R.styleable#Slider_thumbElevation
*/
public void setThumbElevationResource(@DimenRes int elevation) {
setThumbElevation(getResources().getDimension(elevation));
}
/**
* Returns the radius of the thumb.
*
* @see #setThumbRadius(int)
* @see #setThumbRadiusResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
*/
@Dimension
public int getThumbRadius() {
return thumbRadius;
}
/**
* Sets the radius of the thumb in pixels.
*
* @see #getThumbRadius()
* @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
*/
public void setThumbRadius(@IntRange(from = 0) @Dimension int radius) {
if (radius == thumbRadius) {
return;
}
thumbRadius = radius;
thumbDrawable.setShapeAppearanceModel(
ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, thumbRadius).build());
thumbDrawable.setBounds(0, 0, thumbRadius * 2, thumbRadius * 2);
postInvalidate();
}
/**
* Sets the radius of the thumb from a dimension resource.
*
* @see #getThumbRadius()
* @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
*/
public void setThumbRadiusResource(@DimenRes int radius) {
setThumbRadius(getResources().getDimensionPixelSize(radius));
}
/**
* Sets the stroke color for the thumbs. Both thumbStroke color and thumbStroke width must be set
* for a stroke to be drawn.
*
* @param thumbStrokeColor Color to use for the stroke in the thumbs.
* @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeColor
* @see #setThumbStrokeColorResource(int)
* @see #getThumbStrokeColor()
*/
public void setThumbStrokeColor(@Nullable ColorStateList thumbStrokeColor) {
thumbDrawable.setStrokeColor(thumbStrokeColor);
postInvalidate();
}
/**
* Sets the stroke color resource for the thumbs. Both thumbStroke color and thumbStroke width
* must be set for a stroke to be drawn.
*
* @param thumbStrokeColorResourceId Color resource to use for the stroke.
* @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeColor
* @see #setThumbStrokeColor(ColorStateList)
* @see #getThumbStrokeColor()
*/
public void setThumbStrokeColorResource(@ColorRes int thumbStrokeColorResourceId) {
if (thumbStrokeColorResourceId != 0) {
setThumbStrokeColor(
AppCompatResources.getColorStateList(getContext(), thumbStrokeColorResourceId));
}
}
/**
* Gets the stroke color for the thumb.
*
* @return The color used for the stroke in the thumb.
* @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeColor
* @see #setThumbStrokeColor(ColorStateList)
* @see #setThumbStrokeColorResource(int)
*/
public ColorStateList getThumbStrokeColor() {
return thumbDrawable.getStrokeColor();
}
/**
* Sets the stroke width for the thumb. Both thumbStroke color and thumbStroke width must be set
* for a stroke to be drawn.
*
* @param thumbStrokeWidth Stroke width for the thumb
* @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeWidth
* @see #setThumbStrokeWidthResource(int)
* @see #getThumbStrokeWidth()
*/
public void setThumbStrokeWidth(float thumbStrokeWidth) {
thumbDrawable.setStrokeWidth(thumbStrokeWidth);
postInvalidate();
}
/**
* Sets the stroke width dimension resource for the thumb.Both thumbStroke color and thumbStroke
* width must be set for a stroke to be drawn.
*
* @param thumbStrokeWidthResourceId Stroke width dimension resource for the thumb
* @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeWidth
* @see #setThumbStrokeWidth(float)
* @see #getThumbStrokeWidth()
*/
public void setThumbStrokeWidthResource(@DimenRes int thumbStrokeWidthResourceId) {
if (thumbStrokeWidthResourceId != 0) {
setThumbStrokeWidth(getResources().getDimension(thumbStrokeWidthResourceId));
}
}
/**
* Gets the stroke width for the thumb
*
* @return Stroke width for the thumb.
* @attr ref com.google.android.material.R.styleable#Slider_thumbStrokeWidth
* @see #setThumbStrokeWidth(float)
* @see #setThumbStrokeWidthResource(int)
*/
public float getThumbStrokeWidth() {
return thumbDrawable.getStrokeWidth();
}
/**
* Returns the radius of the halo.
*
* @see #setHaloRadius(int)
* @see #setHaloRadiusResource(int)
* @attr ref com.google.android.material.R.styleable#Slider_haloRadius
*/
@Dimension()
public int getHaloRadius() {
return haloRadius;
}
/**
* Sets the radius of the halo in pixels.
*
* @see #getHaloRadius()
* @attr ref com.google.android.material.R.styleable#Slider_haloRadius
*/
public void setHaloRadius(@IntRange(from = 0) @Dimension int radius) {
if (radius == haloRadius) {
return;
}
haloRadius = radius;
Drawable background = getBackground();
if (!shouldDrawCompatHalo() && background instanceof RippleDrawable) {
DrawableUtils.setRippleDrawableRadius((RippleDrawable) background, haloRadius);
return;
}
postInvalidate();
}
/**
* Sets the radius of the halo from a dimension resource.
*
* @see #getHaloRadius()
* @attr ref com.google.android.material.R.styleable#Slider_haloRadius
*/
public void setHaloRadiusResource(@DimenRes int radius) {
setHaloRadius(getResources().getDimensionPixelSize(radius));
}
/**
* Returns the {@link LabelBehavior} used.
*
* @see #setLabelBehavior(int)
* @attr ref com.google.android.material.R.styleable#Slider_labelBehavior
*/
@LabelBehavior
public int getLabelBehavior() {
return labelBehavior;
}
/**
* Determines the {@link LabelBehavior} used.
*
* @see LabelBehavior
* @see #getLabelBehavior()
* @attr ref com.google.android.material.R.styleable#Slider_labelBehavior
*/
public void setLabelBehavior(@LabelBehavior int labelBehavior) {
if (this.labelBehavior != labelBehavior) {
this.labelBehavior = labelBehavior;
requestLayout();
}
}
/** Returns the side padding of the track. */
@Dimension()
public int getTrackSidePadding() {
return trackSidePadding;
}
/** Returns the width of the track in pixels. */
@Dimension()
public int getTrackWidth() {
return trackWidth;
}
/**
* Returns the height of the track in pixels.
*
* @see #setTrackHeight(int)
* @attr ref com.google.android.material.R.styleable#Slider_trackHeight
*/
@Dimension()
public int getTrackHeight() {
return trackHeight;
}
/**
* Set the height of the track in pixels.
*
* @see #getTrackHeight()
* @attr ref com.google.android.material.R.styleable#Slider_trackHeight
*/
public void setTrackHeight(@IntRange(from = 0) @Dimension int trackHeight) {
if (this.trackHeight != trackHeight) {
this.trackHeight = trackHeight;
invalidateTrack();
postInvalidate();
}
}
/**
* Returns the color of the halo.
*
* @see #setHaloTintList(ColorStateList)
* @attr ref com.google.android.material.R.styleable#Slider_haloColor
*/
@NonNull
public ColorStateList getHaloTintList() {
return haloColor;
}
/**
* Sets the color of the halo.
*
* @see #getHaloTintList()
* @attr ref com.google.android.material.R.styleable#Slider_haloColor
*/
public void setHaloTintList(@NonNull ColorStateList haloColor) {
if (haloColor.equals(this.haloColor)) {
return;
}
this.haloColor = haloColor;
Drawable background = getBackground();
if (!shouldDrawCompatHalo() && background instanceof RippleDrawable) {
((RippleDrawable) background).setColor(haloColor);
return;
}
haloPaint.setColor(getColorForState(haloColor));
haloPaint.setAlpha(HALO_ALPHA);
invalidate();
}
/**
* Returns the color of the thumb.
*
* @see #setThumbTintList(ColorStateList)
* @attr ref com.google.android.material.R.styleable#Slider_thumbColor
*/
@NonNull
public ColorStateList getThumbTintList() {
return thumbDrawable.getFillColor();
}
/**
* Sets the color of the thumb.
*
* @see #getThumbTintList()
* @attr ref com.google.android.material.R.styleable#Slider_thumbColor
*/
public void setThumbTintList(@NonNull ColorStateList thumbColor) {
thumbDrawable.setFillColor(thumbColor);
}
/**
* Returns the color of the tick if the active and inactive parts aren't different.
*
* @throws IllegalStateException If {@code tickColorActive} and {@code tickColorInactive} have
* been set to different values.
* @see #setTickTintList(ColorStateList)
* @see #setTickInactiveTintList(ColorStateList)
* @see #setTickActiveTintList(ColorStateList)
* @see #getTickInactiveTintList()
* @see #getTickActiveTintList()
* @attr ref com.google.android.material.R.styleable#Slider_tickColor
*/
@NonNull
public ColorStateList getTickTintList() {
if (!tickColorInactive.equals(tickColorActive)) {
throw new IllegalStateException(
"The inactive and active ticks are different colors. Use the getTickColorInactive() and"
+ " getTickColorActive() methods instead.");
}
return tickColorActive;
}
/**
* Sets the color of the tick marks.
*
* @see #setTickInactiveTintList(ColorStateList)
* @see #setTickActiveTintList(ColorStateList)
* @see #getTickTintList()
* @attr ref com.google.android.material.R.styleable#Slider_tickColor
*/
public void setTickTintList(@NonNull ColorStateList tickColor) {
setTickInactiveTintList(tickColor);
setTickActiveTintList(tickColor);
}
/**
* Returns the color of the ticks on the active portion of the track.
*
* @see #setTickActiveTintList(ColorStateList)
* @see #setTickTintList(ColorStateList)
* @see #getTickTintList()
* @attr ref com.google.android.material.R.styleable#Slider_tickColorActive
*/
@NonNull
public ColorStateList getTickActiveTintList() {
return tickColorActive;
}
/**
* Sets the color of the ticks on the active portion of the track.
*
* @see #getTickActiveTintList()
* @see #setTickTintList(ColorStateList)
* @attr ref com.google.android.material.R.styleable#Slider_tickColorActive
*/
public void setTickActiveTintList(@NonNull ColorStateList tickColor) {
if (tickColor.equals(tickColorActive)) {
return;
}
tickColorActive = tickColor;
activeTicksPaint.setColor(getColorForState(tickColorActive));
invalidate();
}
/**
* Returns the color of the ticks on the inactive portion of the track.
*
* @see #setTickInactiveTintList(ColorStateList)
* @see #setTickTintList(ColorStateList)
* @see #getTickTintList()
* @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive
*/
@NonNull
public ColorStateList getTickInactiveTintList() {
return tickColorInactive;
}
/**
* Sets the color of the ticks on the inactive portion of the track.
*
* @see #getTickInactiveTintList()
* @see #setTickTintList(ColorStateList)
* @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive
*/
public void setTickInactiveTintList(@NonNull ColorStateList tickColor) {
if (tickColor.equals(tickColorInactive)) {
return;
}
tickColorInactive = tickColor;
inactiveTicksPaint.setColor(getColorForState(tickColorInactive));
invalidate();
}
/**
* Returns whether the tick marks are visible. Only used when the slider is in discrete mode.
*
* @see #setTickVisible(boolean)
* @attr ref com.google.android.material.R.styleable#Slider_tickVisible
*/
public boolean isTickVisible() {
return tickVisible;
}
/**
* Sets whether the tick marks are visible. Only used when the slider is in discrete mode.
*
* @param tickVisible The visibility of tick marks.
* @attr ref com.google.android.material.R.styleable#Slider_tickVisible
*/
public void setTickVisible(boolean tickVisible) {
if (this.tickVisible != tickVisible) {
this.tickVisible = tickVisible;
postInvalidate();
}
}
/**
* Returns the color of the track if the active and inactive parts aren't different.
*
* @throws IllegalStateException If {@code trackColorActive} and {@code trackColorInactive} have
* been set to different values.
* @see #setTrackTintList(ColorStateList)
* @see #setTrackInactiveTintList(ColorStateList)
* @see #setTrackActiveTintList(ColorStateList)
* @see #getTrackInactiveTintList()
* @see #getTrackActiveTintList()
* @attr ref com.google.android.material.R.styleable#Slider_trackColor
*/
@NonNull
public ColorStateList getTrackTintList() {
if (!trackColorInactive.equals(trackColorActive)) {
throw new IllegalStateException(
"The inactive and active parts of the track are different colors. Use the"
+ " getInactiveTrackColor() and getActiveTrackColor() methods instead.");
}
return trackColorActive;
}
/**
* Sets the color of the track.
*
* @see #setTrackInactiveTintList(ColorStateList)
* @see #setTrackActiveTintList(ColorStateList)
* @see #getTrackTintList()
* @attr ref com.google.android.material.R.styleable#Slider_trackColor
*/
public void setTrackTintList(@NonNull ColorStateList trackColor) {
setTrackInactiveTintList(trackColor);
setTrackActiveTintList(trackColor);
}
/**
* Returns the color of the active portion of the track.
*
* @see #setTrackActiveTintList(ColorStateList)
* @see #setTrackTintList(ColorStateList)
* @see #getTrackTintList()
* @attr ref com.google.android.material.R.styleable#Slider_trackColorActive
*/
@NonNull
public ColorStateList getTrackActiveTintList() {
return trackColorActive;
}
/**
* Sets the color of the active portion of the track.
*
* @see #getTrackActiveTintList()
* @see #setTrackTintList(ColorStateList)
* @attr ref com.google.android.material.R.styleable#Slider_trackColorActive
*/
public void setTrackActiveTintList(@NonNull ColorStateList trackColor) {
if (trackColor.equals(trackColorActive)) {
return;
}
trackColorActive = trackColor;
activeTrackPaint.setColor(getColorForState(trackColorActive));
invalidate();
}
/**
* Returns the color of the inactive portion of the track.
*
* @see #setTrackInactiveTintList(ColorStateList)
* @see #setTrackTintList(ColorStateList)
* @see #getTrackTintList()
* @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive
*/
@NonNull
public ColorStateList getTrackInactiveTintList() {
return trackColorInactive;
}
/**
* Sets the color of the inactive portion of the track.
*
* @see #getTrackInactiveTintList()
* @see #setTrackTintList(ColorStateList)
* @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive
*/
public void setTrackInactiveTintList(@NonNull ColorStateList trackColor) {
if (trackColor.equals(trackColorInactive)) {
return;
}
trackColorInactive = trackColor;
inactiveTrackPaint.setColor(getColorForState(trackColorInactive));
invalidate();
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
// When we're disabled, set the layer type to hardware so we can clear the track out from behind
// the thumb.
setLayerType(enabled ? LAYER_TYPE_NONE : LAYER_TYPE_HARDWARE, null);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// The label is attached on the Overlay relative to the content.
for (TooltipDrawable label : labels) {
attachLabelToContentView(label);
}
}
private void attachLabelToContentView(TooltipDrawable label) {
label.setRelativeToView(ViewUtils.getContentView(this));
}
@Override
protected void onDetachedFromWindow() {
if (accessibilityEventSender != null) {
removeCallbacks(accessibilityEventSender);
}
for (TooltipDrawable label : labels) {
detachLabelFromContentView(label);
}
super.onDetachedFromWindow();
}
private void detachLabelFromContentView(TooltipDrawable label) {
ViewOverlayImpl contentViewOverlay = ViewUtils.getContentViewOverlay(this);
if (contentViewOverlay != null) {
contentViewOverlay.remove(label);
label.detachView(ViewUtils.getContentView(this));
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(
widthMeasureSpec,
MeasureSpec.makeMeasureSpec(
widgetHeight
+ (labelBehavior == LABEL_WITHIN_BOUNDS ? labels.get(0).getIntrinsicHeight() : 0),
MeasureSpec.EXACTLY));
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
// Update the visible track width.
trackWidth = Math.max(w - trackSidePadding * 2, 0);
// Update the visible tick coordinates.
maybeCalculateTicksCoordinates();
updateHaloHotspot();
}
private void maybeCalculateTicksCoordinates() {
if (stepSize <= 0.0f) {
return;
}
validateConfigurationIfDirty();
int tickCount = (int) ((valueTo - valueFrom) / stepSize + 1);
// Limit the tickCount if they will be too dense.
tickCount = Math.min(tickCount, trackWidth / (trackHeight * 2) + 1);
if (ticksCoordinates == null || ticksCoordinates.length != tickCount * 2) {
ticksCoordinates = new float[tickCount * 2];
}
float interval = trackWidth / (float) (tickCount - 1);
for (int i = 0; i < tickCount * 2; i += 2) {
ticksCoordinates[i] = trackSidePadding + i / 2 * interval;
ticksCoordinates[i + 1] = calculateTop();
}
}
private void updateHaloHotspot() {
// Set the hotspot as the halo if RippleDrawable is being used.
if (!shouldDrawCompatHalo() && getMeasuredWidth() > 0) {
final Drawable background = getBackground();
if (background instanceof RippleDrawable) {
int x = (int) (normalizeValue(values.get(focusedThumbIdx)) * trackWidth + trackSidePadding);
int y = calculateTop();
DrawableCompat.setHotspotBounds(
background, x - haloRadius, y - haloRadius, x + haloRadius, y + haloRadius);
}
}
}
private int calculateTop() {
return trackTop
+ (labelBehavior == LABEL_WITHIN_BOUNDS ? labels.get(0).getIntrinsicHeight() : 0);
}
@Override
protected void onDraw(@NonNull Canvas canvas) {
if (dirtyConfig) {
validateConfigurationIfDirty();
// Update the visible tick coordinates.
maybeCalculateTicksCoordinates();
}
super.onDraw(canvas);
int top = calculateTop();
drawInactiveTrack(canvas, trackWidth, top);
if (Collections.max(getValues()) > valueFrom) {
drawActiveTrack(canvas, trackWidth, top);
}
maybeDrawTicks(canvas);
if ((thumbIsPressed || isFocused()) && isEnabled()) {
maybeDrawHalo(canvas, trackWidth, top);
// Draw labels if there is an active thumb.
if (activeThumbIdx != -1) {
ensureLabels();
}
}
drawThumbs(canvas, trackWidth, top);
}
/**
* Returns a float array where {@code float[0]} is the normalized left position and {@code
* float[1]} is the normalized right position of the range.
*/
private float[] getActiveRange() {
float max = Collections.max(getValues());
float min = Collections.min(getValues());
float left = normalizeValue(values.size() == 1 ? valueFrom : min);
float right = normalizeValue(max);
// In RTL we draw things in reverse, so swap the left and right range values
return isRtl() ? new float[] {right, left} : new float[] {left, right};
}
private void drawInactiveTrack(@NonNull Canvas canvas, int width, int top) {
float[] activeRange = getActiveRange();
float right = trackSidePadding + activeRange[1] * width;
if (right < trackSidePadding + width) {
canvas.drawLine(right, top, trackSidePadding + width, top, inactiveTrackPaint);
}
// Also draw inactive track to the left if there is any
float left = trackSidePadding + activeRange[0] * width;
if (left > trackSidePadding) {
canvas.drawLine(trackSidePadding, top, left, top, inactiveTrackPaint);
}
}
/**
* Returns a number between 0 and 1 indicating where on the track this value should sit with 0
* being on the far left, and 1 on the far right.
*/
private float normalizeValue(float value) {
float normalized = (value - valueFrom) / (valueTo - valueFrom);
if (isRtl()) {
return 1 - normalized;
}
return normalized;
}
private void drawActiveTrack(@NonNull Canvas canvas, int width, int top) {
float[] activeRange = getActiveRange();
float right = trackSidePadding + activeRange[1] * width;
float left = trackSidePadding + activeRange[0] * width;
canvas.drawLine(left, top, right, top, activeTrackPaint);
}
private void maybeDrawTicks(@NonNull Canvas canvas) {
if (!tickVisible || stepSize <= 0.0f) {
return;
}
float[] activeRange = getActiveRange();
int leftPivotIndex = pivotIndex(ticksCoordinates, activeRange[0]);
int rightPivotIndex = pivotIndex(ticksCoordinates, activeRange[1]);
// Draw inactive ticks to the left of the smallest thumb.
canvas.drawPoints(ticksCoordinates, 0, leftPivotIndex * 2, inactiveTicksPaint);
// Draw active ticks between the thumbs.
canvas.drawPoints(
ticksCoordinates,
leftPivotIndex * 2,
rightPivotIndex * 2 - leftPivotIndex * 2,
activeTicksPaint);
// Draw inactive ticks to the right of the largest thumb.
canvas.drawPoints(
ticksCoordinates,
rightPivotIndex * 2,
ticksCoordinates.length - rightPivotIndex * 2,
inactiveTicksPaint);
}
private void drawThumbs(@NonNull Canvas canvas, int width, int top) {
// Clear out the track behind the thumb if we're in a disable state since the thumb is
// transparent.
if (!isEnabled()) {
for (Float value : values) {
canvas.drawCircle(
trackSidePadding + normalizeValue(value) * width, top, thumbRadius, thumbPaint);
}
}
for (Float value : values) {
canvas.save();
canvas.translate(
trackSidePadding + (int) (normalizeValue(value) * width) - thumbRadius,
top - thumbRadius);
thumbDrawable.draw(canvas);
canvas.restore();
}
}
private void maybeDrawHalo(@NonNull Canvas canvas, int width, int top) {
// Only draw the halo for devices that aren't using the ripple.
if (shouldDrawCompatHalo()) {
int centerX = (int) (trackSidePadding + normalizeValue(values.get(focusedThumbIdx)) * width);
if (VERSION.SDK_INT < VERSION_CODES.P) {
// In this case we can clip the rect to allow drawing outside the bounds.
canvas.clipRect(
centerX - haloRadius,
top - haloRadius,
centerX + haloRadius,
top + haloRadius,
Op.UNION);
}
canvas.drawCircle(centerX, top, haloRadius, haloPaint);
}
}
private boolean shouldDrawCompatHalo() {
return forceDrawCompatHalo
|| VERSION.SDK_INT < VERSION_CODES.LOLLIPOP
|| !(getBackground() instanceof RippleDrawable);
}
@Override
public boolean onTouchEvent(@NonNull MotionEvent event) {
if (!isEnabled()) {
return false;
}
float x = event.getX();
touchPosition = (x - trackSidePadding) / trackWidth;
touchPosition = Math.max(0, touchPosition);
touchPosition = Math.min(1, touchPosition);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
touchDownX = x;
// If we're inside a scrolling container,
// we should start dragging in ACTION_MOVE
if (isInScrollingContainer()) {
break;
}
getParent().requestDisallowInterceptTouchEvent(true);
if (!pickActiveThumb()) {
// Couldn't determine the active thumb yet.
break;
}
requestFocus();
thumbIsPressed = true;
snapTouchPosition();
updateHaloHotspot();
invalidate();
onStartTrackingTouch();
break;
case MotionEvent.ACTION_MOVE:
if (!thumbIsPressed) {
// Check if we're trying to scroll instead of dragging this Slider
if (abs(x - touchDownX) < scaledTouchSlop) {
return false;
}
getParent().requestDisallowInterceptTouchEvent(true);
onStartTrackingTouch();
}
if (!pickActiveThumb()) {
// Couldn't determine the active thumb yet.
break;
}
thumbIsPressed = true;
snapTouchPosition();
updateHaloHotspot();
invalidate();
break;
case MotionEvent.ACTION_UP:
thumbIsPressed = false;
// We need to handle a tap if the last event was down at the same point.
if (lastEvent != null
&& lastEvent.getActionMasked() == MotionEvent.ACTION_DOWN
&& abs(lastEvent.getX() - event.getX()) <= scaledTouchSlop
&& abs(lastEvent.getY() - event.getY()) <= scaledTouchSlop) {
pickActiveThumb();
}
if (activeThumbIdx != -1) {
snapTouchPosition();
activeThumbIdx = -1;
}
for (TooltipDrawable label : labels) {
ViewUtils.getContentViewOverlay(this).remove(label);
}
onStopTrackingTouch();
invalidate();
break;
default:
// Nothing to do in this case.
}
// Set if the thumb is pressed. This will cause the ripple to be drawn.
setPressed(thumbIsPressed);
lastEvent = MotionEvent.obtain(event);
return true;
}
/**
* Calculates the index the closest tick coordinates that the thumb should snap to.
*
* @param coordinates Tick coordinates defined in {@code #setTicksCoordinates()}.
* @param position Actual thumb position.
* @return Index of the closest tick coordinate.
*/
private static int pivotIndex(float[] coordinates, float position) {
return Math.round(position * (coordinates.length / 2 - 1));
}
private double snapPosition(float position) {
if (stepSize > 0.0f) {
int stepCount = (int) ((valueTo - valueFrom) / stepSize);
return Math.round(position * stepCount) / (double) stepCount;
}
return position;
}
/**
* Tries to pick the active thumb if one hasn't already been set. This will pick the closest thumb
* if there is only one thumb under the touch position. If there is more than one thumb under the
* touch position, it will wait for enough drag left or right to determine which thumb to pick.
*/
protected boolean pickActiveThumb() {
if (activeThumbIdx != -1) {
return true;
}
float touchValue = getValueOfTouchPositionAbsolute();
float touchX = valueToX(touchValue);
activeThumbIdx = 0;
float activeThumbDiff = abs(values.get(activeThumbIdx) - touchValue);
for (int i = 1; i < values.size(); i++) {
float valueDiff = abs(values.get(i) - touchValue);
float valueX = valueToX(values.get(i));
if (compare(valueDiff, activeThumbDiff) > 1) {
break;
}
boolean movingForward = isRtl() ? (valueX - touchX) > 0 : (valueX - touchX) < 0;
// Keep replacing the activeThumbIdx, while the diff decreases.
// If the diffs are equal we'll pick the thumb based on which direction we are dragging.
if (compare(valueDiff, activeThumbDiff) < 0) {
activeThumbDiff = valueDiff;
activeThumbIdx = i;
continue;
}
if (compare(valueDiff, activeThumbDiff) == 0) {
// Two thumbs on the same value and we don't have enough movement to use direction yet.
if (abs(valueX - touchX) < scaledTouchSlop) {
activeThumbIdx = -1;
return false;
}
if (movingForward) {
activeThumbDiff = valueDiff;
activeThumbIdx = i;
}
}
}
return activeThumbIdx != -1;
}
private float getValueOfTouchPositionAbsolute() {
float position = touchPosition;
if (isRtl()) {
position = 1 - position;
}
return (position * (valueTo - valueFrom) + valueFrom);
}
/**
* Snaps the thumb position to the closest tick coordinates in discrete mode, and the input
* position in continuous mode.
*
* @return true, if {@code #thumbPosition is updated}; false, otherwise.
*/
private boolean snapTouchPosition() {
return snapActiveThumbToValue(getValueOfTouchPosition());
}
private boolean snapActiveThumbToValue(float value) {
return snapThumbToValue(activeThumbIdx, value);
}
private boolean snapThumbToValue(int idx, float value) {
// Check if the new value equals a value that was already set.
if (abs(value - values.get(idx)) < THRESHOLD) {
return false;
}
float newValue = getClampedValue(idx, value);
// Replace the old value with the new value of the touch position.
values.set(idx, newValue);
focusedThumbIdx = idx;
dispatchOnChangedFromUser(idx);
return true;
}
/** Thumbs cannot cross each other, clamp the value to a bound or the value next to it. */
private float getClampedValue(int idx, float value) {
float minSeparation = stepSize == 0 ? getMinSeparation() : 0;
minSeparation = separationUnit == UNIT_PX ? dimenToValue(minSeparation) : minSeparation;
if (isRtl()) {
minSeparation = -minSeparation;
}
float upperBound = idx + 1 >= values.size() ? valueTo : values.get(idx + 1) - minSeparation;
float lowerBound = idx - 1 < 0 ? valueFrom : values.get(idx - 1) + minSeparation;
return clamp(value, lowerBound, upperBound);
}
private float dimenToValue(float dimen) {
if (dimen == 0) {
return 0;
}
return ((dimen - trackSidePadding) / trackWidth) * (valueFrom - valueTo) + valueFrom;
}
protected void setSeparationUnit(int separationUnit) {
this.separationUnit = separationUnit;
}
protected float getMinSeparation() {
return 0;
}
private float getValueOfTouchPosition() {
double position = snapPosition(touchPosition);
// We might need to invert the touch position to get the correct value.
if (isRtl()) {
position = 1 - position;
}
return (float) (position * (valueTo - valueFrom) + valueFrom);
}
private float valueToX(float value) {
return normalizeValue(value) * trackWidth + trackSidePadding;
}
private void ensureLabels() {
if (labelBehavior == LABEL_GONE) {
// If the label shouldn't be drawn we can skip this.
return;
}
Iterator