/* * Copyright (C) 2019 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 com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; 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.Parcel; import android.os.Parcelable; import androidx.annotation.ColorInt; import androidx.annotation.DimenRes; import androidx.annotation.Dimension; 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 android.util.AttributeSet; import android.util.Log; import android.view.InflateException; import android.view.MotionEvent; import android.view.View; 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.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.util.Locale; /** * A widget that allows picking a value within a given range by sliding a thumb along a horizontal * line. * *

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 OnChangeListener} interface defines a callback to be invoked when the slider * changes. * *

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: * *

* *

Note: the slider does not accept {@link View.OnFocusChangeListener}s. * * @attr ref com.google.android.material.R.styleable#Slider_android_stepSize * @attr ref com.google.android.material.R.styleable#Slider_android_value * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom * @attr ref com.google.android.material.R.styleable#Slider_android_valueTo * @attr ref com.google.android.material.R.styleable#Slider_floatingLabel * @attr ref com.google.android.material.R.styleable#Slider_haloColor * @attr ref com.google.android.material.R.styleable#Slider_haloRadius * @attr ref com.google.android.material.R.styleable#Slider_labelStyle * @attr ref com.google.android.material.R.styleable#Slider_thumbColor * @attr ref com.google.android.material.R.styleable#Slider_thumbElevation * @attr ref com.google.android.material.R.styleable#Slider_thumbRadius * @attr ref com.google.android.material.R.styleable#Slider_tickColor * @attr ref com.google.android.material.R.styleable#Slider_tickColorActive * @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive * @attr ref com.google.android.material.R.styleable#Slider_trackColor * @attr ref com.google.android.material.R.styleable#Slider_trackColorActive * @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive * @attr ref com.google.android.material.R.styleable#Slider_trackHeight */ public class Slider extends View { private static final String TAG = Slider.class.getSimpleName(); private static final String EXCEPTION_ILLEGAL_VALUE = "Slider value must be greater or equal to valueFrom, and lower or equal to valueTo"; private static final String EXCEPTION_ILLEGAL_DISCRETE_VALUE = "Value must be equal to valueFrom plus a multiple of stepSize when using stepSize"; private static final String EXCEPTION_ILLEGAL_VALUE_FROM = "valueFrom must be smaller than valueTo"; private static final String EXCEPTION_ILLEGAL_VALUE_TO = "valueTo must be greater than valueFrom"; private static final String EXCEPTION_ILLEGAL_STEP_SIZE = "The stepSize must be 0, or a factor of the valueFrom-valueTo range"; private static final int HALO_ALPHA = 63; private static final double THRESHOLD = .0001; private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_Slider; @NonNull private final Paint inactiveTrackPaint; @NonNull private final Paint activeTrackPaint; @NonNull private final Paint thumbPaint; @NonNull private final Paint haloPaint; @NonNull private final Paint inactiveTicksPaint; @NonNull private final Paint activeTicksPaint; @NonNull private TooltipDrawable label; private int widgetHeight; private boolean floatingLabel; private int trackHeight; private int trackSidePadding; private int trackTop; private int thumbRadius; private int haloRadius; private int labelPadding; private OnChangeListener listener; private LabelFormatter formatter; private boolean thumbIsPressed = false; private float valueFrom; private float valueTo; private float thumbPosition = 0.0f; // The position of the thumb normalised to a [0.0, 1.0] range. private float stepSize = 0.0f; private float[] ticksCoordinates; private float[] visibleTicksCoordinates; private int trackWidth; private boolean forceDrawCompatHalo; @NonNull private ColorStateList haloColor; @NonNull private ColorStateList tickColorActive; @NonNull private ColorStateList tickColorInactive; @NonNull private ColorStateList trackColorActive; @NonNull private ColorStateList trackColorInactive; @NonNull private final MaterialShapeDrawable thumbDrawable = new MaterialShapeDrawable(); /** Interface definition for a callback invoked when a slider's value is changed. */ public interface OnChangeListener { void onValueChange(Slider slider, float value); } /** * Interface definition for applying custom formatting to the text displayed inside the bubble * shown when a slider is used in discrete mode. */ public interface LabelFormatter { @NonNull String getFormattedValue(float value); } /** * A simple implementation of the {@link LabelFormatter} interface, that limits the number * displayed inside a discrete slider's bubble to three digits, and a single-character suffix that * denotes magnitude (e.g.: 1.5K, 2.2M, 1.3B, 2T). */ public static final class BasicLabelFormatter implements LabelFormatter { private static final long TRILLION = 1000000000000L; private static final int BILLION = 1000000000; private static final int MILLION = 1000000; private static final int THOUSAND = 1000; @NonNull @Override public String getFormattedValue(float value) { if (value >= TRILLION) { return String.format(Locale.US, "%.1fT", value / TRILLION); } else if (value >= BILLION) { return String.format(Locale.US, "%.1fB", value / BILLION); } else if (value >= MILLION) { return String.format(Locale.US, "%.1fM", value / MILLION); } else if (value >= THOUSAND) { return String.format(Locale.US, "%.1fK", value / THOUSAND); } else { return String.format(Locale.US, "%.0f", value); } } } public Slider(@NonNull Context context) { this(context, null); } public Slider(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, R.attr.sliderStyle); } public Slider(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr); // Ensure we are using the correctly themed context rather than the context that was passed in. context = getContext(); loadResources(context.getResources()); processAttributes(context, attrs, defStyleAttr); inactiveTrackPaint = new Paint(); inactiveTrackPaint.setStyle(Style.STROKE); inactiveTrackPaint.setStrokeWidth(trackHeight); inactiveTrackPaint.setStrokeCap(Cap.ROUND); activeTrackPaint = new Paint(); activeTrackPaint.setStyle(Style.STROKE); activeTrackPaint.setStrokeWidth(trackHeight); activeTrackPaint.setStrokeCap(Cap.ROUND); thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG); thumbPaint.setStyle(Style.FILL); thumbPaint.setXfermode(new PorterDuffXfermode(Mode.CLEAR)); haloPaint = new Paint(Paint.ANTI_ALIAS_FLAG); haloPaint.setStyle(Style.FILL); inactiveTicksPaint = new Paint(); inactiveTicksPaint.setStyle(Style.STROKE); inactiveTicksPaint.setStrokeWidth(trackHeight / 2.0f); inactiveTicksPaint.setStrokeCap(Cap.ROUND); activeTicksPaint = new Paint(); activeTicksPaint.setStyle(Style.STROKE); activeTicksPaint.setStrokeWidth(trackHeight / 2.0f); activeTicksPaint.setStrokeCap(Cap.ROUND); Drawable background = getBackground(); if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { if (background instanceof RippleDrawable) { ((RippleDrawable) background).setColor(haloColor); DrawableUtils.setRippleDrawableRadius(background, haloRadius); } // Because the RippleDrawable can draw outside the bounds of the view, we can set the layer // type to hardware so we can use PorterDuffXfermode when drawing. setLayerType(LAYER_TYPE_HARDWARE, null); } super.setOnFocusChangeListener( new OnFocusChangeListener() { @Override public void onFocusChange(View v, boolean hasFocus) { invalidate(); } }); setFocusable(true); // Set up the thumb drawable to always show the compat shadow. thumbDrawable.setShadowCompatibilityMode(MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS); } private void loadResources(@NonNull Resources resources) { widgetHeight = resources.getDimensionPixelSize(R.dimen.mtrl_slider_widget_height); trackSidePadding = resources.getDimensionPixelOffset(R.dimen.mtrl_slider_track_side_padding); trackTop = resources.getDimensionPixelOffset(R.dimen.mtrl_slider_track_top); labelPadding = resources.getDimensionPixelSize(R.dimen.mtrl_slider_label_padding); } private void processAttributes(Context context, AttributeSet attrs, int defStyleAttr) { TypedArray a = ThemeEnforcement.obtainStyledAttributes( context, attrs, R.styleable.Slider, defStyleAttr, DEF_STYLE_RES); valueFrom = a.getFloat(R.styleable.Slider_android_valueFrom, 0.0f); valueTo = a.getFloat(R.styleable.Slider_android_valueTo, 1.0f); setValue(a.getFloat(R.styleable.Slider_android_value, valueFrom)); stepSize = a.getFloat(R.styleable.Slider_android_stepSize, 0.0f); boolean hasTrackColor = a.hasValue(R.styleable.Slider_trackColor); int trackColorInactiveRes = hasTrackColor ? R.styleable.Slider_trackColor : R.styleable.Slider_trackColorInactive; int trackColorActiveRes = hasTrackColor ? R.styleable.Slider_trackColor : R.styleable.Slider_trackColorActive; trackColorInactive = MaterialResources.getColorStateList(context, a, trackColorInactiveRes); trackColorActive = MaterialResources.getColorStateList(context, a, trackColorActiveRes); ColorStateList thumbColor = MaterialResources.getColorStateList(context, a, R.styleable.Slider_thumbColor); thumbDrawable.setFillColor(thumbColor); haloColor = MaterialResources.getColorStateList(context, a, R.styleable.Slider_haloColor); boolean hasTickColor = a.hasValue(R.styleable.Slider_tickColor); int tickColorInactiveRes = hasTickColor ? R.styleable.Slider_tickColor : R.styleable.Slider_tickColorInactive; int tickColorActiveRes = hasTickColor ? R.styleable.Slider_tickColor : R.styleable.Slider_tickColorActive; tickColorInactive = MaterialResources.getColorStateList(context, a, tickColorInactiveRes); tickColorActive = MaterialResources.getColorStateList(context, a, tickColorActiveRes); label = parseLabelDrawable(context, a); setThumbRadius(a.getDimensionPixelSize(R.styleable.Slider_thumbRadius, 0)); haloRadius = a.getDimensionPixelSize(R.styleable.Slider_haloRadius, 0); setThumbElevation(a.getDimension(R.styleable.Slider_thumbElevation, 0)); trackHeight = a.getDimensionPixelSize(R.styleable.Slider_trackHeight, 0); floatingLabel = a.getBoolean(R.styleable.Slider_floatingLabel, true); a.recycle(); validateValueFrom(); validateValueTo(); validateStepSize(); } @NonNull private TooltipDrawable parseLabelDrawable(@NonNull Context context, @NonNull TypedArray a) { return TooltipDrawable.createFromAttributes( context, null, 0, a.getResourceId(R.styleable.Slider_labelStyle, R.style.Widget_MaterialComponents_Tooltip)); } private void validateValueFrom() { if (valueFrom >= valueTo) { Log.e(TAG, EXCEPTION_ILLEGAL_VALUE_FROM); throw new IllegalArgumentException(EXCEPTION_ILLEGAL_VALUE_FROM); } } private void validateValueTo() { if (valueTo <= valueFrom) { Log.e(TAG, EXCEPTION_ILLEGAL_VALUE_TO); throw new IllegalArgumentException(EXCEPTION_ILLEGAL_VALUE_TO); } } private void validateStepSize() { if (stepSize < 0.0f) { Log.e(TAG, EXCEPTION_ILLEGAL_STEP_SIZE); throw new IllegalArgumentException(EXCEPTION_ILLEGAL_STEP_SIZE); } else if (stepSize > 0.0f && ((valueTo - valueFrom) / stepSize) % 1 > THRESHOLD) { Log.e(TAG, EXCEPTION_ILLEGAL_STEP_SIZE); throw new IllegalArgumentException(EXCEPTION_ILLEGAL_STEP_SIZE); } } /** * Returns the slider's {@code valueFrom} value. * * @see #setValueFrom(float) * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom */ public float getValueFrom() { return valueFrom; } /** * Sets the slider's {@code valueFrom} value. * *

The {@code valueFrom} value must be strictly lower than the {@code valueTo} value. If that * is not the case, an {@link IllegalArgumentException} will be thrown. * * @param valueFrom The minimum value for the slider's range of values * @throws IllegalArgumentException If {@code valueFrom} is greater or equal to {@code valueTo} * @see #getValueFrom() * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom */ public void setValueFrom(float valueFrom) { this.valueFrom = valueFrom; validateValueFrom(); } /** * 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 IllegalArgumentException} will be thrown. * * @param valueTo The maximum value for the slider's range of values * @throws IllegalArgumentException If {@code valueTo} is lesser or equal to {@code valueFrom} * @see #getValueTo() * @attr ref com.google.android.material.R.styleable#Slider_android_valueTo */ public void setValueTo(float valueTo) { this.valueTo = valueTo; validateValueTo(); } /** * Returns the value of the slider. * * @see #setValue(float) * @attr ref com.google.android.material.R.styleable#Slider_android_value */ public float getValue() { return thumbPosition * (valueTo - valueFrom) + valueFrom; } /** * Sets the value of the slider. * *

The thumb value must be greater or equal to {@code valueFrom}, and lesser or equal to {@code * valueTo}. If that is not the case, an {@link IllegalArgumentException} will be thrown. * *

If the slider is in discrete mode (i.e. the tick increment value is greater than 0), the * thumb's value 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 IllegalArgumentException} will be thrown. * * @param value The value to which to set the slider * @throws IllegalArgumentException If the value is not within {@code valueFrom} and {@code * valueTo}. If stepSize is greater than 0 and value does not fall on a tick * @see #getValue() * @attr ref com.google.android.material.R.styleable#Slider_android_value */ public void setValue(float value) { if (isValueValid(value)) { thumbPosition = (value - valueFrom) / (valueTo - valueFrom); if (hasOnChangeListener()) { listener.onValueChange(this, getValue()); } invalidate(); } } private boolean isValueValid(float value) { if (value < valueFrom || value > valueTo) { Log.e(TAG, EXCEPTION_ILLEGAL_VALUE); return false; } if (stepSize > 0.0f && ((valueFrom - value) / stepSize) % 1 > THRESHOLD) { Log.e(TAG, EXCEPTION_ILLEGAL_DISCRETE_VALUE); return false; } return true; } /** * Returns the step size used to mark the ticks. * *

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

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

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

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 not a factor of the {@code * valueFrom}-{@code valueTo} range. 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) { this.stepSize = stepSize; validateStepSize(); maybeUpdateTrackWidthAndTicksCoordinates(); postInvalidate(); } /** * Returns {@code true} if the slider has an {@link OnChangeListener} attached, {@code false} * otherwise. */ public boolean hasOnChangeListener() { return listener != null; } /** * Registers a callback to be invoked when the slider changes. * * @param listener The callback to run when the slider changes */ public void setOnChangeListener(@Nullable OnChangeListener listener) { this.listener = listener; } /** * 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); postInvalidate(); } /** * 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) { 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)); } /** * 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) { haloRadius = radius; 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)); } /** * If the height of this view is increased to make space for the label. * * @see #setFloatingLabel(boolean) * @attr ref com.google.android.material.R.styleable#Slider_floatingLabel */ public boolean isFloatingLabel() { return floatingLabel; } /** * If true, the label will be drawn on top of views above this one, otherwise height will be added * to make space for the label. * * @see #isFloatingLabel() * @attr ref com.google.android.material.R.styleable#Slider_floatingLabel */ public void setFloatingLabel(boolean floatingLabel) { if (this.floatingLabel != floatingLabel) { this.floatingLabel = floatingLabel; requestLayout(); } } /** * 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(); maybeUpdateTrackWidthAndTicksCoordinates(); postInvalidate(); } } @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. When enabled set the layer type to none so that the halo can be drawn outside the // bounds of the slider. After Lollipop we use Ripple for the halo, and an Overlay for the // marker so we don't need to worry about drawing outside the bounds. if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP) { setLayerType(enabled ? LAYER_TYPE_NONE : LAYER_TYPE_HARDWARE, null); } } @Override public void setOnFocusChangeListener(View.OnFocusChangeListener listener) {} @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); // The label is attached on the Overlay relative to the content. label.setRelativeToView(ViewUtils.getContentView(this)); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); ViewUtils.getContentViewOverlay(this).remove(label); label.detachView(ViewUtils.getContentView(this)); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure( widthMeasureSpec, MeasureSpec.makeMeasureSpec( widgetHeight + (floatingLabel ? 0 : label.getIntrinsicHeight()), MeasureSpec.EXACTLY)); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); updateTrackWidthAndTicksCoordinates(w); updateHaloHotSpot(); } private void maybeUpdateTrackWidthAndTicksCoordinates() { if (ViewCompat.isLaidOut(this)) { // If we're already laid out we need to update the ticks. updateTrackWidthAndTicksCoordinates(getWidth()); } } private void updateTrackWidthAndTicksCoordinates(int viewWidth) { trackWidth = viewWidth - trackSidePadding * 2; if (stepSize > 0.0f) { int tickCount = (int) ((valueTo - valueFrom) / stepSize + 1); if (ticksCoordinates == null || ticksCoordinates.length != tickCount * 2) { ticksCoordinates = new float[tickCount * 2]; } setTicksCoordinates(ticksCoordinates); // Limit the tickCount if they will be too dense. tickCount = Math.min(tickCount, trackWidth / (trackHeight * 2) + 1); if (visibleTicksCoordinates == null || visibleTicksCoordinates.length != tickCount * 2) { visibleTicksCoordinates = new float[tickCount * 2]; } setTicksCoordinates(visibleTicksCoordinates); } } private void setTicksCoordinates(float[] coordinates) { int tickCount = coordinates.length / 2; float interval = trackWidth / (float) (tickCount - 1); for (int i = 0; i < tickCount * 2; i += 2) { coordinates[i] = trackSidePadding + i / 2 * interval; coordinates[i + 1] = calculateTop(); } } private void updateHaloHotSpot() { // Set the hotspot as the halo above lollipop. if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && getMeasuredWidth() > 0) { final Drawable background = getBackground(); if (background instanceof RippleDrawable) { int x = (int) (thumbPosition * trackWidth + trackSidePadding); int y = calculateTop(); DrawableCompat.setHotspotBounds( background, x - haloRadius, y - haloRadius, x + haloRadius, y + haloRadius); } } } private int calculateTop() { return trackTop + (floatingLabel ? 0 : label.getIntrinsicHeight()); } @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); int top = calculateTop(); drawInactiveTrack(canvas, trackWidth, top); if (thumbPosition > 0.0f) { drawActiveTrack(canvas, trackWidth, top); } if (stepSize > 0.0f) { drawTicks(canvas); } if ((thumbIsPressed || isFocused()) && isEnabled()) { maybeDrawHalo(canvas, trackWidth, top); } drawThumb(canvas, trackWidth, top); } private void drawInactiveTrack(@NonNull Canvas canvas, int width, int top) { float right = trackSidePadding + thumbPosition * width; if (right < trackSidePadding + width) { canvas.drawLine(right, top, trackSidePadding + width, top, inactiveTrackPaint); } } private void drawActiveTrack(@NonNull Canvas canvas, int width, int top) { float left = trackSidePadding + thumbPosition * width; canvas.drawLine(trackSidePadding, top, left, top, activeTrackPaint); } private void drawTicks(@NonNull Canvas canvas) { int pivotIndex = pivotIndex(visibleTicksCoordinates); canvas.drawPoints(visibleTicksCoordinates, 0, pivotIndex * 2, activeTicksPaint); canvas.drawPoints( visibleTicksCoordinates, pivotIndex * 2, visibleTicksCoordinates.length - pivotIndex * 2, inactiveTicksPaint); } private void drawThumb(@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()) { canvas.drawCircle(trackSidePadding + thumbPosition * width, top, thumbRadius, thumbPaint); } canvas.save(); canvas.translate( trackSidePadding + (int) (thumbPosition * 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 which don't support the ripple. if (forceDrawCompatHalo || VERSION.SDK_INT < VERSION_CODES.LOLLIPOP) { int centerX = (int) (trackSidePadding + thumbPosition * width); if (VERSION.SDK_INT < VERSION_CODES.LOLLIPOP) { // 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); } } @Override public boolean onTouchEvent(@NonNull MotionEvent event) { if (!isEnabled()) { return false; } float x = event.getX(); float position = (x - trackSidePadding) / trackWidth; position = Math.max(0, position); position = Math.min(1, position); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: getParent().requestDisallowInterceptTouchEvent(true); requestFocus(); thumbIsPressed = true; thumbPosition = position; snapThumbPosition(); updateHaloHotSpot(); ensureLabel(); updateLabelPosition(); invalidate(); if (hasOnChangeListener()) { listener.onValueChange(this, getValue()); } break; case MotionEvent.ACTION_MOVE: thumbPosition = position; snapThumbPosition(); updateHaloHotSpot(); ensureLabel(); updateLabelPosition(); invalidate(); if (hasOnChangeListener()) { listener.onValueChange(this, getValue()); } break; case MotionEvent.ACTION_UP: getParent().requestDisallowInterceptTouchEvent(false); thumbIsPressed = false; thumbPosition = position; snapThumbPosition(); ViewUtils.getContentViewOverlay(this).remove(label); 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); return true; } private void ensureLabel() { float value = getValue(); if (hasLabelFormatter()) { label.setText(formatter.getFormattedValue(value)); } else { label.setText(String.format((int) value == value ? "%.0f" : "%.2f", value)); } } /** Calculates the index of the thumb for the given tick coordinates */ private int pivotIndex(float[] coordinates) { return Math.round(thumbPosition * (coordinates.length / 2 - 1)); } private void snapThumbPosition() { if (stepSize > 0.0f) { int intervalsCovered = pivotIndex(ticksCoordinates); thumbPosition = (float) intervalsCovered / (ticksCoordinates.length / 2 - 1); } } private void updateLabelPosition() { int left = trackSidePadding + (int) (thumbPosition * trackWidth) - label.getIntrinsicWidth() / 2; int top = calculateTop() - (labelPadding + thumbRadius); label.setBounds(left, top - label.getIntrinsicHeight(), left + label.getIntrinsicWidth(), top); // Calculate the difference between the bounds of this view and the bounds of the root view to // correctly position this view in the overlay layer. Rect rect = new Rect(label.getBounds()); DescendantOffsetUtils.offsetDescendantRect(ViewUtils.getContentView(this), this, rect); label.setBounds(rect); ViewUtils.getContentViewOverlay(this).add(label); } private void invalidateTrack() { inactiveTrackPaint.setStrokeWidth(trackHeight); activeTrackPaint.setStrokeWidth(trackHeight); inactiveTicksPaint.setStrokeWidth(trackHeight / 2.0f); activeTicksPaint.setStrokeWidth(trackHeight / 2.0f); } @Override protected void drawableStateChanged() { super.drawableStateChanged(); inactiveTrackPaint.setColor(getColorForState(trackColorInactive)); activeTrackPaint.setColor(getColorForState(trackColorActive)); inactiveTicksPaint.setColor(getColorForState(tickColorInactive)); activeTicksPaint.setColor(getColorForState(tickColorActive)); if (label.isStateful()) { label.setState(getDrawableState()); } if (thumbDrawable.isStateful()) { thumbDrawable.setState(getDrawableState()); } haloPaint.setColor(getColorForState(haloColor)); haloPaint.setAlpha(HALO_ALPHA); } @ColorInt private int getColorForState(@NonNull ColorStateList colorStateList) { return colorStateList.getColorForState(getDrawableState(), colorStateList.getDefaultColor()); } @VisibleForTesting void forceDrawCompatHalo(boolean force) { forceDrawCompatHalo = force; } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); SliderState sliderState = new SliderState(superState); sliderState.valueFrom = valueFrom; sliderState.valueTo = valueTo; sliderState.thumbPosition = thumbPosition; sliderState.stepSize = stepSize; sliderState.hasFocus = hasFocus(); return sliderState; } @Override protected void onRestoreInstanceState(Parcelable state) { SliderState sliderState = (SliderState) state; super.onRestoreInstanceState(sliderState.getSuperState()); valueFrom = sliderState.valueFrom; valueTo = sliderState.valueTo; thumbPosition = sliderState.thumbPosition; stepSize = sliderState.stepSize; if (sliderState.hasFocus) { requestFocus(); } if (hasOnChangeListener()) { listener.onValueChange(this, getValue()); } } static class SliderState extends BaseSavedState { float valueFrom; float valueTo; float thumbPosition; float stepSize; float[] ticksCoordinates; boolean hasFocus; public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @NonNull @Override public SliderState createFromParcel(@NonNull Parcel source) { return new SliderState(source); } @NonNull @Override public SliderState[] newArray(int size) { return new SliderState[size]; } }; SliderState(Parcelable superState) { super(superState); } private SliderState(@NonNull Parcel source) { super(source); valueFrom = source.readFloat(); valueTo = source.readFloat(); thumbPosition = source.readFloat(); stepSize = source.readFloat(); source.readFloatArray(ticksCoordinates); hasFocus = source.createBooleanArray()[0]; } @Override public void writeToParcel(@NonNull Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeFloat(valueFrom); dest.writeFloat(valueTo); dest.writeFloat(thumbPosition); dest.writeFloat(stepSize); dest.writeFloatArray(ticksCoordinates); boolean[] booleans = new boolean[1]; booleans[0] = hasFocus; dest.writeBooleanArray(booleans); } } }