/*
 * 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)}.
 *
 * <p>The {@link LabelFormatter} interface defines a formatter to be used to render text within the
 * value indicator label on interaction.
 *
 * <p>{@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..).
 *
 * <p>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:
 *
 * <ul>
 *   <li>{@code haloColor}: the color of the halo around the thumb.
 *   <li>{@code haloRadius}: The radius of the halo around the thumb.
 *   <li>{@code labelBehavior}: The behavior of the label which can be {@code LABEL_FLOATING},
 *       {@code LABEL_WITHIN_BOUNDS}, or {@code LABEL_GONE}. See {@link LabelBehavior} for more
 *       information.
 *   <li>{@code labelStyle}: the style to apply to the value indicator {@link TooltipDrawable}.
 *   <li>{@code thumbColor}: the color of the slider's thumb.
 *   <li>{@code thumbStrokeColor}: the color of the thumb's stroke.
 *   <li>{@code thumbStrokeWidth}: the width of the thumb's stroke.
 *   <li>{@code thumbElevation}: the elevation of the slider's thumb.
 *   <li>{@code thumbRadius}: The radius of the slider's thumb.
 *   <li>{@code tickColorActive}: the color of the slider's tick marks for the active part of the
 *       track. Only used when the slider is in discrete mode.
 *   <li>{@code tickColorInactive}: the color of the slider's tick marks for the inactive part of
 *       the track. Only used when the slider is in discrete mode.
 *   <li>{@code tickColor}: the color of the slider's tick marks. Only used when the slider is in
 *       discrete mode. This is a short hand for setting both the {@code tickColorActive} and {@code
 *       tickColorInactive} to the same thing. This takes precedence over {@code tickColorActive}
 *       and {@code tickColorInactive}.
 *   <li>{@code tickVisible}: Whether to show the tick marks. Only used when the slider is in
 *       discrete mode.
 *   <li>{@code trackColorActive}: The color of the active part of the track.
 *   <li>{@code trackColorInactive}: The color of the inactive part of the track.
 *   <li>{@code trackColor}: The color of the whole track. This is a short hand for setting both the
 *       {@code trackColorActive} and {@code trackColorInactive} to the same thing. This takes
 *       precedence over {@code trackColorActive} and {@code trackColorInactive}.
 *   <li>{@code trackHeight}: The height of the track.
 * </ul>
 *
 * <p>The following XML attributes are used to set the slider's various parameters of operation:
 *
 * <ul>
 *   <li>{@code android:valueFrom}: <b>Required.</b> The slider's minimum value. This attribute must
 *       be less than {@code valueTo} or an {@link IllegalStateException} will be thrown when the
 *       view is laid out.
 *   <li>{@code android:valueTo}: <b>Required.</b> The slider's maximum value. This attribute must
 *       be greater than {@code valueFrom} or an {@link IllegalStateException} will be thrown when
 *       the view is laid out.
 *   <li>{@code android:value}: <b>Optional.</b> The initial value of the slider. If not specified,
 *       the slider's minimum value {@code android:valueFrom} is used.
 *   <li>{@code android:stepSize}: <b>Optional.</b> This value dictates whether the slider operates
 *       in continuous mode, or in discrete mode. If missing or equal to 0, the slider operates in
 *       continuous mode. If greater than 0 and evenly divides the range described by {@code
 *       valueFrom} and {@code valueTo}, the slider operates in discrete mode. If negative an {@link
 *       IllegalArgumentException} is thrown, or if greater than 0 but not a factor of the range
 *       described by {@code valueFrom} and {@code valueTo}, an {@link IllegalStateException} will
 *       be thrown when the view is laid out.
 * </ul>
 *
 * @attr ref com.google.android.material.R.styleable#Slider_android_enabled
 * @attr ref com.google.android.material.R.styleable#Slider_android_stepSize
 * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom
 * @attr ref com.google.android.material.R.styleable#Slider_android_valueTo
 * @attr ref com.google.android.material.R.styleable#Slider_haloColor
 * @attr ref com.google.android.material.R.styleable#Slider_haloRadius
 * @attr ref com.google.android.material.R.styleable#Slider_labelBehavior
 * @attr ref com.google.android.material.R.styleable#Slider_labelStyle
 * @attr ref com.google.android.material.R.styleable#Slider_thumbColor
 * @attr ref com.google.android.material.R.styleable#Slider_thumbElevation
 * @attr ref com.google.android.material.R.styleable#Slider_thumbRadius
 * @attr ref com.google.android.material.R.styleable#Slider_tickColor
 * @attr ref com.google.android.material.R.styleable#Slider_tickColorActive
 * @attr ref com.google.android.material.R.styleable#Slider_tickColorInactive
 * @attr ref com.google.android.material.R.styleable#Slider_tickVisible
 * @attr ref com.google.android.material.R.styleable#Slider_trackColor
 * @attr ref com.google.android.material.R.styleable#Slider_trackColorActive
 * @attr ref com.google.android.material.R.styleable#Slider_trackColorInactive
 * @attr ref com.google.android.material.R.styleable#Slider_trackHeight
 */
abstract class BaseSlider<
        S extends BaseSlider<S, L, T>,
        L extends BaseOnChangeListener<S>,
        T extends BaseOnSliderTouchListener<S>>
    extends View {

  private static final String TAG = BaseSlider.class.getSimpleName();
  private static final String EXCEPTION_ILLEGAL_VALUE =
      "Slider value(%s) must be greater or equal to valueFrom(%s), and lower or equal to"
          + " valueTo(%s)";
  private static final String EXCEPTION_ILLEGAL_DISCRETE_VALUE =
      "Value(%s) must be equal to valueFrom(%s) plus a multiple of stepSize(%s) when using"
          + " stepSize(%s)";
  private static final String EXCEPTION_ILLEGAL_VALUE_FROM =
      "valueFrom(%s) must be smaller than valueTo(%s)";
  private static final String EXCEPTION_ILLEGAL_VALUE_TO =
      "valueTo(%s) must be greater than valueFrom(%s)";
  private static final String EXCEPTION_ILLEGAL_STEP_SIZE =
      "The stepSize(%s) must be 0, or a factor of the valueFrom(%s)-valueTo(%s) range";
  private static final String EXCEPTION_ILLEGAL_MIN_SEPARATION =
      "minSeparation(%s) must be greater or equal to 0";
  private static final String EXCEPTION_ILLEGAL_MIN_SEPARATION_STEP_SIZE_UNIT =
      "minSeparation(%s) cannot be set as a dimension when using stepSize(%s)";
  private static final String EXCEPTION_ILLEGAL_MIN_SEPARATION_STEP_SIZE =
      "minSeparation(%s) must be greater or equal and a multiple of stepSize(%s) when using"
          + " stepSize(%s)";
  private static final String WARNING_FLOATING_POINT_ERROR =
      "Floating point value used for %s(%s). Using floats can have rounding errors which may"
          + " result in incorrect values. Instead, consider using integers with a custom"
          + " LabelFormatter to display the value correctly.";

  private static final int TIMEOUT_SEND_ACCESSIBILITY_EVENT = 200;
  private static final int HALO_ALPHA = 63;
  private static final double THRESHOLD = .0001;

  static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_Slider;
  static final int UNIT_VALUE = 1;
  static final int UNIT_PX = 0;

  private static final int DEFAULT_LABEL_ANIMATION_ENTER_DURATION = 83;
  private static final int DEFAULT_LABEL_ANIMATION_EXIT_DURATION = 117;
  private static final int LABEL_ANIMATION_ENTER_DURATION_ATTR = R.attr.motionDurationMedium4;
  private static final int LABEL_ANIMATION_EXIT_DURATION_ATTR = R.attr.motionDurationShort3;
  private static final int LABEL_ANIMATION_ENTER_EASING_ATTR =
      R.attr.motionEasingEmphasizedInterpolator;
  private static final int LABEL_ANIMATION_EXIT_EASING_ATTR =
      R.attr.motionEasingEmphasizedAccelerateInterpolator;

  @Dimension private static final int MIN_TOUCH_TARGET_DP = 48;

  @NonNull private final Paint inactiveTrackPaint;
  @NonNull private final Paint activeTrackPaint;
  @NonNull private final Paint thumbPaint;
  @NonNull private final Paint haloPaint;
  @NonNull private final Paint inactiveTicksPaint;
  @NonNull private final Paint activeTicksPaint;
  @NonNull private final AccessibilityHelper accessibilityHelper;
  private final AccessibilityManager accessibilityManager;
  private AccessibilityEventSender accessibilityEventSender;

  private int labelStyle;
  @NonNull private final List<TooltipDrawable> labels = new ArrayList<>();
  @NonNull private final List<L> changeListeners = new ArrayList<>();
  @NonNull private final List<T> touchListeners = new ArrayList<>();

  // Whether the labels are showing or in the process of animating in.
  private boolean labelsAreAnimatedIn = false;
  private ValueAnimator labelsInAnimator;
  private ValueAnimator labelsOutAnimator;

  private final int scaledTouchSlop;

  private int minTrackSidePadding;
  private int defaultThumbRadius;
  private int defaultTrackHeight;
  private int defaultTickActiveRadius;
  private int defaultTickInactiveRadius;

  @Dimension(unit = Dimension.PX)
  private int minTouchTargetSize;

  private int minWidgetHeight;
  private int widgetHeight;
  private int labelBehavior;
  private int trackHeight;
  private int trackSidePadding;
  private int thumbRadius;
  private int haloRadius;
  private int labelPadding;
  private float touchDownX;
  private MotionEvent lastEvent;
  private LabelFormatter formatter;
  private boolean thumbIsPressed = false;
  private float valueFrom;
  private float valueTo;
  // Holds the values set to this slider. We keep this array sorted in order to check if the value
  // has been changed when a new value is set and to find the minimum and maximum values.
  private ArrayList<Float> values = new ArrayList<>();
  // The index of the currently touched thumb.
  private int activeThumbIdx = -1;
  // The index of the currently focused thumb.
  private int focusedThumbIdx = -1;
  private float stepSize = 0.0f;
  private float[] ticksCoordinates;
  private boolean tickVisible = true;
  private int tickActiveRadius;
  private int tickInactiveRadius;
  private int trackWidth;
  private boolean forceDrawCompatHalo;
  private boolean isLongPress = false;
  private boolean dirtyConfig;

  @NonNull private ColorStateList haloColor;
  @NonNull private ColorStateList tickColorActive;
  @NonNull private ColorStateList tickColorInactive;
  @NonNull private ColorStateList trackColorActive;
  @NonNull private ColorStateList trackColorInactive;

  @NonNull private final MaterialShapeDrawable defaultThumbDrawable = new MaterialShapeDrawable();
  @Nullable private Drawable customThumbDrawable;
  @NonNull private List<Drawable> customThumbDrawablesForValues = Collections.emptyList();

  private float touchPosition;
  @SeparationUnit private int separationUnit = UNIT_PX;

  /**
   * Determines the behavior of the label which can be any of the following.
   *
   * <ul>
   *   <li>{@code LABEL_FLOATING}: The label will only be visible on interaction. It will float
   *       above the slider and may cover views above this one. This is the default and recommended
   *       behavior.
   *   <li>{@code LABEL_WITHIN_BOUNDS}: The label will only be visible on interaction. The label
   *       will always be drawn within the bounds of this view. This means extra space will be
   *       visible above the slider when the label is not visible.
   *   <li>{@code LABEL_GONE}: The label will never be drawn.
   *   <li>{@code LABEL_VISIBLE}: The label will never be hidden.
   * </ul>
   */
  @IntDef({LABEL_FLOATING, LABEL_WITHIN_BOUNDS, LABEL_GONE, LABEL_VISIBLE})
  @Retention(RetentionPolicy.SOURCE)
  @interface LabelBehavior {}

  @IntDef({UNIT_PX, UNIT_VALUE})
  @Retention(RetentionPolicy.SOURCE)
  @interface SeparationUnit {}

  public BaseSlider(@NonNull Context context) {
    this(context, null);
  }

  public BaseSlider(@NonNull Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, R.attr.sliderStyle);
  }

  public BaseSlider(
      @NonNull Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) {
    super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr);
    // Ensure we are using the correctly themed context rather than the context that was passed in.
    context = getContext();

    inactiveTrackPaint = new Paint();
    inactiveTrackPaint.setStyle(Style.STROKE);
    inactiveTrackPaint.setStrokeCap(Cap.ROUND);

    activeTrackPaint = new Paint();
    activeTrackPaint.setStyle(Style.STROKE);
    activeTrackPaint.setStrokeCap(Cap.ROUND);

    thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    thumbPaint.setStyle(Style.FILL);
    thumbPaint.setXfermode(new PorterDuffXfermode(Mode.CLEAR));

    haloPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    haloPaint.setStyle(Style.FILL);

    inactiveTicksPaint = new Paint();
    inactiveTicksPaint.setStyle(Style.STROKE);
    inactiveTicksPaint.setStrokeCap(Cap.ROUND);

    activeTicksPaint = new Paint();
    activeTicksPaint.setStyle(Style.STROKE);
    activeTicksPaint.setStrokeCap(Cap.ROUND);

    loadResources(context.getResources());
    processAttributes(context, attrs, defStyleAttr);

    setFocusable(true);
    setClickable(true);

    // Set up the thumb drawable to always show the compat shadow.
    defaultThumbDrawable.setShadowCompatibilityMode(
        MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS);

    scaledTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

    accessibilityHelper = new AccessibilityHelper(this);
    ViewCompat.setAccessibilityDelegate(this, accessibilityHelper);

    accessibilityManager =
        (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
  }

  private void loadResources(@NonNull Resources resources) {
    minWidgetHeight = resources.getDimensionPixelSize(R.dimen.mtrl_slider_widget_height);

    minTrackSidePadding = resources.getDimensionPixelOffset(R.dimen.mtrl_slider_track_side_padding);
    trackSidePadding = minTrackSidePadding;

    defaultThumbRadius = resources.getDimensionPixelSize(R.dimen.mtrl_slider_thumb_radius);
    defaultTrackHeight = resources.getDimensionPixelSize(R.dimen.mtrl_slider_track_height);

    defaultTickActiveRadius = resources.getDimensionPixelSize(R.dimen.mtrl_slider_tick_radius);
    defaultTickInactiveRadius = resources.getDimensionPixelSize(R.dimen.mtrl_slider_tick_radius);

    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);

    labelStyle =
        a.getResourceId(R.styleable.Slider_labelStyle, R.style.Widget_MaterialComponents_Tooltip);

    valueFrom = a.getFloat(R.styleable.Slider_android_valueFrom, 0.0f);
    valueTo = a.getFloat(R.styleable.Slider_android_valueTo, 1.0f);
    setValues(valueFrom);
    stepSize = a.getFloat(R.styleable.Slider_android_stepSize, 0.0f);

    float defaultMinTouchTargetSize =
        (float) Math.ceil(ViewUtils.dpToPx(getContext(), MIN_TOUCH_TARGET_DP));
    minTouchTargetSize =
        (int)
            Math.ceil(
                a.getDimension(R.styleable.Slider_minTouchTargetSize, defaultMinTouchTargetSize));

    boolean hasTrackColor = a.hasValue(R.styleable.Slider_trackColor);

    int trackColorInactiveRes =
        hasTrackColor ? R.styleable.Slider_trackColor : R.styleable.Slider_trackColorInactive;
    int trackColorActiveRes =
        hasTrackColor ? R.styleable.Slider_trackColor : R.styleable.Slider_trackColorActive;

    ColorStateList trackColorInactive =
        MaterialResources.getColorStateList(context, a, trackColorInactiveRes);
    setTrackInactiveTintList(
        trackColorInactive != null
            ? trackColorInactive
            : AppCompatResources.getColorStateList(
                context, R.color.material_slider_inactive_track_color));
    ColorStateList trackColorActive =
        MaterialResources.getColorStateList(context, a, trackColorActiveRes);
    setTrackActiveTintList(
        trackColorActive != null
            ? trackColorActive
            : AppCompatResources.getColorStateList(
                context, R.color.material_slider_active_track_color));
    ColorStateList thumbColor =
        MaterialResources.getColorStateList(context, a, R.styleable.Slider_thumbColor);
    defaultThumbDrawable.setFillColor(thumbColor);

    if (a.hasValue(R.styleable.Slider_thumbStrokeColor)) {
      setThumbStrokeColor(
          MaterialResources.getColorStateList(context, a, R.styleable.Slider_thumbStrokeColor));
    }
    setThumbStrokeWidth(a.getDimension(R.styleable.Slider_thumbStrokeWidth, 0));

    ColorStateList haloColor =
        MaterialResources.getColorStateList(context, a, R.styleable.Slider_haloColor);
    setHaloTintList(
        haloColor != null
            ? haloColor
            : AppCompatResources.getColorStateList(context, R.color.material_slider_halo_color));

    tickVisible = a.getBoolean(R.styleable.Slider_tickVisible, true);
    boolean hasTickColor = a.hasValue(R.styleable.Slider_tickColor);
    int tickColorInactiveRes =
        hasTickColor ? R.styleable.Slider_tickColor : R.styleable.Slider_tickColorInactive;
    int tickColorActiveRes =
        hasTickColor ? R.styleable.Slider_tickColor : R.styleable.Slider_tickColorActive;
    ColorStateList tickColorInactive =
        MaterialResources.getColorStateList(context, a, tickColorInactiveRes);
    setTickInactiveTintList(
        tickColorInactive != null
            ? tickColorInactive
            : AppCompatResources.getColorStateList(
                context, R.color.material_slider_inactive_tick_marks_color));
    ColorStateList tickColorActive =
        MaterialResources.getColorStateList(context, a, tickColorActiveRes);
    setTickActiveTintList(
        tickColorActive != null
            ? tickColorActive
            : AppCompatResources.getColorStateList(
                context, R.color.material_slider_active_tick_marks_color));

    setThumbRadius(a.getDimensionPixelSize(R.styleable.Slider_thumbRadius, 0));
    setHaloRadius(a.getDimensionPixelSize(R.styleable.Slider_haloRadius, 0));

    setThumbElevation(a.getDimension(R.styleable.Slider_thumbElevation, 0));

    setTrackHeight(a.getDimensionPixelSize(R.styleable.Slider_trackHeight, 0));

    setTickActiveRadius(a.getDimensionPixelSize(R.styleable.Slider_tickRadiusActive, 0));

    setTickInactiveRadius(a.getDimensionPixelSize(R.styleable.Slider_tickRadiusInactive, 0));

    setLabelBehavior(a.getInt(R.styleable.Slider_labelBehavior, LABEL_FLOATING));

    if (!a.getBoolean(R.styleable.Slider_android_enabled, true)) {
      setEnabled(false);
    }

    a.recycle();
  }

  private boolean maybeIncreaseTrackSidePadding() {
    int increasedSidePaddingByThumb = max(thumbRadius - defaultThumbRadius, 0);
    int increasedSidePaddingByTrack = max((trackHeight - defaultTrackHeight) / 2, 0);
    int increasedSidePaddingByActiveTick = max(tickActiveRadius - defaultTickActiveRadius, 0);
    int increasedSidePaddingByInactiveTick = max(tickInactiveRadius - defaultTickInactiveRadius, 0);
    int newTrackSidePadding =
        minTrackSidePadding + max(max(increasedSidePaddingByThumb, increasedSidePaddingByTrack),
            max(increasedSidePaddingByActiveTick, increasedSidePaddingByInactiveTick));

    if (trackSidePadding == newTrackSidePadding) {
      return false;
    }
    trackSidePadding = newTrackSidePadding;
    if (ViewCompat.isLaidOut(this)) {
      updateTrackWidth(getWidth());
    }
    return true;
  }

  private void validateValueFrom() {
    if (valueFrom >= valueTo) {
      throw new IllegalStateException(
          String.format(EXCEPTION_ILLEGAL_VALUE_FROM, valueFrom, valueTo));
    }
  }

  private void validateValueTo() {
    if (valueTo <= valueFrom) {
      throw new IllegalStateException(
          String.format(EXCEPTION_ILLEGAL_VALUE_TO, valueTo, valueFrom));
    }
  }

  private boolean valueLandsOnTick(float value) {
    // Check that the value is a multiple of stepSize given the offset of valueFrom.
    return isMultipleOfStepSize(value - valueFrom);
  }

  private boolean isMultipleOfStepSize(float value) {
    // We're using BigDecimal here to avoid floating point rounding errors.
    double result =
        new BigDecimal(Float.toString(value))
            .divide(new BigDecimal(Float.toString(stepSize)), MathContext.DECIMAL64)
            .doubleValue();

    // If the result is a whole number, it means the value is a multiple of stepSize.
    return Math.abs(Math.round(result) - result) < THRESHOLD;
  }

  private void validateStepSize() {
    if (stepSize > 0.0f && !valueLandsOnTick(valueTo)) {
      throw new IllegalStateException(
          String.format(EXCEPTION_ILLEGAL_STEP_SIZE, stepSize, valueFrom, valueTo));
    }
  }

  private void validateValues() {
    for (Float value : values) {
      if (value < valueFrom || value > valueTo) {
        throw new IllegalStateException(
            String.format(EXCEPTION_ILLEGAL_VALUE, value, valueFrom, valueTo));
      }
      if (stepSize > 0.0f && !valueLandsOnTick(value)) {
        throw new IllegalStateException(
            String.format(EXCEPTION_ILLEGAL_DISCRETE_VALUE, value, valueFrom, stepSize, stepSize));
      }
    }
  }

  private void validateMinSeparation() {
    final float minSeparation = getMinSeparation();
    if (minSeparation < 0) {
      throw new IllegalStateException(
          String.format(EXCEPTION_ILLEGAL_MIN_SEPARATION, minSeparation));
    }
    if (stepSize > 0 && minSeparation > 0) {
      if (separationUnit != UNIT_VALUE) {
        throw new IllegalStateException(
            String.format(
                EXCEPTION_ILLEGAL_MIN_SEPARATION_STEP_SIZE_UNIT, minSeparation, stepSize));
      }
      if (minSeparation < stepSize || !isMultipleOfStepSize(minSeparation)) {
        throw new IllegalStateException(
            String.format(
                EXCEPTION_ILLEGAL_MIN_SEPARATION_STEP_SIZE, minSeparation, stepSize, stepSize));
      }
    }
  }

  private void warnAboutFloatingPointError() {
    if (stepSize == 0) {
      // Only warn if slider uses a step value.
      return;
    }

    if ((int) stepSize != stepSize) {
      Log.w(TAG, String.format(WARNING_FLOATING_POINT_ERROR, "stepSize", stepSize));
    }

    if ((int) valueFrom != valueFrom) {
      Log.w(TAG, String.format(WARNING_FLOATING_POINT_ERROR, "valueFrom", valueFrom));
    }

    if ((int) valueTo != valueTo) {
      Log.w(TAG, String.format(WARNING_FLOATING_POINT_ERROR, "valueTo", valueTo));
    }
  }

  private void validateConfigurationIfDirty() {
    if (dirtyConfig) {
      validateValueFrom();
      validateValueTo();
      validateStepSize();
      validateValues();
      validateMinSeparation();
      warnAboutFloatingPointError();
      dirtyConfig = false;
    }
  }

  /**
   * Returns the slider's {@code valueFrom} value.
   *
   * @see #setValueFrom(float)
   * @attr ref com.google.android.material.R.styleable#Slider_android_valueFrom
   */
  public float getValueFrom() {
    return valueFrom;
  }

  /**
   * Sets the slider's {@code valueFrom} value.
   *
   * <p>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.
   *
   * <p>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<Float> getValues() {
    return new ArrayList<>(values);
  }

  /**
   * Sets multiple values for the slider. Each value will represent a different thumb.
   *
   * <p>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.
   *
   * <p>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<Float> list = new ArrayList<>();
    Collections.addAll(list, values);
    setValuesInternal(list);
  }

  /**
   * Sets multiple values for the slider. Each value will represent a different thumb.
   *
   * <p>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.
   *
   * <p>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<Float> values) {
    setValuesInternal(new ArrayList<>(values));
  }

  /**
   * This method assumes the list passed in is a copy. It is split out so we can call it from {@link
   * #setValues(Float...)} and {@link #setValues(List)}
   */
  private void setValuesInternal(@NonNull ArrayList<Float> values) {
    if (values.isEmpty()) {
      throw new IllegalArgumentException("At least one value must be set");
    }

    Collections.sort(values);

    if (this.values.size() == values.size()) {
      if (this.values.equals(values)) {
        return;
      }
    }

    this.values = values;
    dirtyConfig = true;
    // Only update the focused thumb index. The active thumb index will be updated on touch.
    focusedThumbIdx = 0;
    updateHaloHotspot();
    createLabelPool();
    dispatchOnChangedProgrammatically();
    postInvalidate();
  }

  private void createLabelPool() {
    // If there are too many labels, remove the extra ones from the end.
    if (labels.size() > values.size()) {
      List<TooltipDrawable> tooltipDrawables = labels.subList(values.size(), labels.size());
      for (TooltipDrawable label : tooltipDrawables) {
        if (ViewCompat.isAttachedToWindow(this)) {
          detachLabelFromContentView(label);
        }
      }
      tooltipDrawables.clear();
    }

    // If there's not enough labels, add more.
    while (labels.size() < values.size()) {
      // Because there's currently no way to copy the TooltipDrawable we use this to make more
      // if more thumbs are added.
      TooltipDrawable tooltipDrawable =
          TooltipDrawable.createFromAttributes(getContext(), null, 0, labelStyle);
      labels.add(tooltipDrawable);
      if (ViewCompat.isAttachedToWindow(this)) {
        attachLabelToContentView(tooltipDrawable);
      }
    }

    // Add a stroke if there is more than one label for when they overlap.
    int strokeWidth = labels.size() == 1 ? 0 : 1;
    for (TooltipDrawable label : labels) {
      label.setStrokeWidth(strokeWidth);
    }
  }

  /**
   * Returns the step size used to mark the ticks.
   *
   * <p>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.
   *
   * <p>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.
   *
   * <p>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.
   *
   * <p>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.
   *
   * <p>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.
   *
   * <p>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 (isPotentialVerticalScroll(event)) {
          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 (isPotentialVerticalScroll(event) && 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();
          updateHaloHotspot();
          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<TooltipDrawable> labelItr = labels.iterator();

    for (int i = 0; i < values.size() && labelItr.hasNext(); i++) {
      if (i == focusedThumbIdx) {
        // We position the focused thumb last so it's displayed on top, so skip it for now.
        continue;
      }

      setValueForLabel(labelItr.next(), values.get(i));
    }

    if (!labelItr.hasNext()) {
      throw new IllegalStateException(
          String.format(
              "Not enough labels(%d) to display all the values(%d)", labels.size(), values.size()));
    }

    // Now set the label for the focused thumb so it's on top.
    setValueForLabel(labelItr.next(), values.get(focusedThumbIdx));
  }

  private String formatValue(float value) {
    if (hasLabelFormatter()) {
      return formatter.getFormattedValue(value);
    }

    return String.format((int) value == value ? "%.0f" : "%.2f", value);
  }

  private void setValueForLabel(TooltipDrawable label, float value) {
    label.setText(formatValue(value));

    int left =
        trackSidePadding
            + (int) (normalizeValue(value) * trackWidth)
            - label.getIntrinsicWidth() / 2;
    int top = calculateTrackCenter() - (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);
  }

  /**
   * If this returns true, we can't start dragging the Slider immediately when we receive a {@link
   * MotionEvent#ACTION_DOWN}. Instead, we must wait for a {@link MotionEvent#ACTION_MOVE}. Copied
   * and modified from hidden method of {@link View} isInScrollingContainer.
   *
   * @return true if any of this View's parents is a scrolling View and can scroll vertically.
   */
  private boolean isInVerticalScrollingContainer() {
    ViewParent p = getParent();
    while (p instanceof ViewGroup) {
      ViewGroup parent = (ViewGroup) p;
      boolean canScrollVertically = parent.canScrollVertically(1) || parent.canScrollVertically(-1);
      if (canScrollVertically && parent.shouldDelayChildPressedState()) {
        return true;
      }
      p = p.getParent();
    }
    return false;
  }

  private static boolean isMouseEvent(MotionEvent event) {
    return event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
  }

  private boolean isPotentialVerticalScroll(MotionEvent event) {
    return !isMouseEvent(event) && isInVerticalScrollingContainer();
  }

  @SuppressWarnings("unchecked")
  private void dispatchOnChangedProgrammatically() {
    for (L listener : changeListeners) {
      for (Float value : values) {
        listener.onValueChange((S) this, value, false);
      }
    }
  }

  @SuppressWarnings("unchecked")
  private void dispatchOnChangedFromUser(int idx) {
    for (L listener : changeListeners) {
      listener.onValueChange((S) this, values.get(idx), true);
    }
    if (accessibilityManager != null && accessibilityManager.isEnabled()) {
      scheduleAccessibilityEventSender(idx);
    }
  }

  @SuppressWarnings("unchecked")
  private void onStartTrackingTouch() {
    for (T listener : touchListeners) {
      listener.onStartTrackingTouch((S) this);
    }
  }

  @SuppressWarnings("unchecked")
  private void onStopTrackingTouch() {
    for (T listener : touchListeners) {
      listener.onStopTrackingTouch((S) this);
    }
  }

  @Override
  protected void drawableStateChanged() {
    super.drawableStateChanged();

    inactiveTrackPaint.setColor(getColorForState(trackColorInactive));
    activeTrackPaint.setColor(getColorForState(trackColorActive));
    inactiveTicksPaint.setColor(getColorForState(tickColorInactive));
    activeTicksPaint.setColor(getColorForState(tickColorActive));
    for (TooltipDrawable label : labels) {
      if (label.isStateful()) {
        label.setState(getDrawableState());
      }
    }
    if (defaultThumbDrawable.isStateful()) {
      defaultThumbDrawable.setState(getDrawableState());
    }
    haloPaint.setColor(getColorForState(haloColor));
    haloPaint.setAlpha(HALO_ALPHA);
  }

  @ColorInt
  private int getColorForState(@NonNull ColorStateList colorStateList) {
    return colorStateList.getColorForState(getDrawableState(), colorStateList.getDefaultColor());
  }

  @VisibleForTesting
  void forceDrawCompatHalo(boolean force) {
    forceDrawCompatHalo = force;
  }

  @Override
  public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
    if (!isEnabled()) {
      return super.onKeyDown(keyCode, event);
    }

    // If there's only one thumb, we can select it right away.
    if (values.size() == 1) {
      activeThumbIdx = 0;
    }

    // If there is no active thumb, key events will be used to pick the thumb to change.
    if (activeThumbIdx == -1) {
      Boolean handled = onKeyDownNoActiveThumb(keyCode, event);
      return handled != null ? handled : super.onKeyDown(keyCode, event);
    }

    isLongPress |= event.isLongPress();
    Float increment = calculateIncrementForKey(keyCode);
    if (increment != null) {
      if (snapActiveThumbToValue(values.get(activeThumbIdx) + increment)) {
        updateHaloHotspot();
        postInvalidate();
      }
      return true;
    }
    switch (keyCode) {
      case KeyEvent.KEYCODE_TAB:
        if (event.hasNoModifiers()) {
          return moveFocus(1);
        }

        if (event.isShiftPressed()) {
          return moveFocus(-1);
        }
        return false;
      case KeyEvent.KEYCODE_DPAD_CENTER:
      case KeyEvent.KEYCODE_ENTER:
        activeThumbIdx = -1;
        postInvalidate();
        return true;
      default:
        // Nothing to do in this case.
    }

    return super.onKeyDown(keyCode, event);
  }

  @Nullable
  private Boolean onKeyDownNoActiveThumb(int keyCode, @NonNull KeyEvent event) {
    switch (keyCode) {
      case KeyEvent.KEYCODE_TAB:
        if (event.hasNoModifiers()) {
          return moveFocus(1);
        }

        if (event.isShiftPressed()) {
          return moveFocus(-1);
        }
        return false;
      case KeyEvent.KEYCODE_DPAD_LEFT:
        moveFocusInAbsoluteDirection(-1);
        return true;
      case KeyEvent.KEYCODE_MINUS:
        moveFocus(-1);
        return true;
      case KeyEvent.KEYCODE_DPAD_RIGHT:
        moveFocusInAbsoluteDirection(1);
        return true;
      case KeyEvent.KEYCODE_EQUALS:
        // Numpad Plus == Shift + Equals, at least in AVD, so fall through.
      case KeyEvent.KEYCODE_PLUS:
        moveFocus(1);
        return true;
      case KeyEvent.KEYCODE_DPAD_CENTER:
      case KeyEvent.KEYCODE_ENTER:
        activeThumbIdx = focusedThumbIdx;
        postInvalidate();
        return true;
      default:
        // Nothing to do in this case.
    }

    return null;
  }

  @Override
  public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
    isLongPress = false;
    return super.onKeyUp(keyCode, event);
  }

  final boolean isRtl() {
    return ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL;
  }

  /**
   * Attempts to move focus to next or previous thumb <i>independent of layout direction</i> and
   * returns whether the focused thumb changed. If focused thumb didn't change, we're at the view
   * boundary for specified {@code direction} and focus may be moved to next or previous view
   * instead.
   *
   * @see #moveFocusInAbsoluteDirection(int)
   */
  private boolean moveFocus(int direction) {
    int oldFocusedThumbIdx = focusedThumbIdx;
    // Prevent integer overflow.
    final long newFocusedThumbIdx = (long) oldFocusedThumbIdx + direction;
    focusedThumbIdx = (int) clamp(newFocusedThumbIdx, 0, values.size() - 1);
    if (focusedThumbIdx == oldFocusedThumbIdx) {
      // Move focus to next or previous view.
      return false;
    }
    if (activeThumbIdx != -1) {
      activeThumbIdx = focusedThumbIdx;
    }
    updateHaloHotspot();
    postInvalidate();
    return true;
  }

  /**
   * Attempts to move focus to the <i>left or right</i> of currently focused thumb and returns
   * whether the focused thumb changed. If focused thumb didn't change, we're at the view boundary
   * for specified {@code direction} and focus may be moved to next or previous view instead.
   *
   * @see #moveFocus(int)
   */
  private boolean moveFocusInAbsoluteDirection(int direction) {
    if (isRtl()) {
      // Prevent integer overflow.
      direction = direction == Integer.MIN_VALUE ? Integer.MAX_VALUE : -direction;
    }
    return moveFocus(direction);
  }

  @Nullable
  private Float calculateIncrementForKey(int keyCode) {
    // If this is a long press, increase the increment so it will only take around 20 steps.
    // Otherwise choose the smallest valid increment.
    float increment = isLongPress ? calculateStepIncrement(20) : calculateStepIncrement();
    switch (keyCode) {
      case KeyEvent.KEYCODE_DPAD_LEFT:
        return isRtl() ? increment : -increment;
      case KeyEvent.KEYCODE_DPAD_RIGHT:
        return isRtl() ? -increment : increment;
      case KeyEvent.KEYCODE_MINUS:
        return -increment;
      case KeyEvent.KEYCODE_EQUALS:
        // Numpad Plus == Shift + Equals, at least in AVD, so fall through.
      case KeyEvent.KEYCODE_PLUS:
        return increment;
      default:
        return null;
    }
  }

  /** Returns a small valid step increment to use when adding an offset to an existing value */
  private float calculateStepIncrement() {
    return stepSize == 0 ? 1 : stepSize;
  }

  /**
   * Returns a valid increment based on the {@code stepSize} (if it's set) that will allow
   * approximately {@code stepFactor} steps to cover the whole range.
   */
  private float calculateStepIncrement(int stepFactor) {
    float increment = calculateStepIncrement();
    float numSteps = (valueTo - valueFrom) / increment;
    if (numSteps <= stepFactor) {
      return increment;
    }

    return Math.round((numSteps / stepFactor)) * increment;
  }

  @Override
  protected void onFocusChanged(
      boolean gainFocus, int direction, @Nullable Rect previouslyFocusedRect) {
    super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
    if (!gainFocus) {
      activeThumbIdx = -1;
      accessibilityHelper.clearKeyboardFocusForVirtualView(focusedThumbIdx);
    } else {
      focusThumbOnFocusGained(direction);
      accessibilityHelper.requestKeyboardFocusForVirtualView(focusedThumbIdx);
    }
  }

  private void focusThumbOnFocusGained(int direction) {
    switch (direction) {
      case FOCUS_BACKWARD:
        moveFocus(Integer.MAX_VALUE);
        break;
      case FOCUS_LEFT:
        moveFocusInAbsoluteDirection(Integer.MAX_VALUE);
        break;
      case FOCUS_FORWARD:
        moveFocus(Integer.MIN_VALUE);
        break;
      case FOCUS_RIGHT:
        moveFocusInAbsoluteDirection(Integer.MIN_VALUE);
        break;
      case FOCUS_UP:
      case FOCUS_DOWN:
      default:
        // Don't make assumptions about where exactly focus came from. Use previously focused thumb.
    }
  }

  @VisibleForTesting
  final int getAccessibilityFocusedVirtualViewId() {
    return accessibilityHelper.getAccessibilityFocusedVirtualViewId();
  }

  @NonNull
  @Override
  public CharSequence getAccessibilityClassName() {
    return SeekBar.class.getName();
  }

  @Override
  public boolean dispatchHoverEvent(@NonNull MotionEvent event) {
    return accessibilityHelper.dispatchHoverEvent(event) || super.dispatchHoverEvent(event);
  }

  @Override
  public boolean dispatchKeyEvent(@NonNull KeyEvent event) {
    // We explicitly don't pass the key event to the accessibilityHelper because it doesn't handle
    // focus correctly in some cases (Such as moving left after moving right a few times).
    return super.dispatchKeyEvent(event);
  }

  /**
   * Schedule a command for sending an accessibility event. </br> Note: A command is used to ensure
   * that accessibility events are sent at most one in a given time frame to save system resources
   * while the value changes quickly.
   */
  private void scheduleAccessibilityEventSender(int idx) {
    if (accessibilityEventSender == null) {
      accessibilityEventSender = new AccessibilityEventSender();
    } else {
      removeCallbacks(accessibilityEventSender);
    }
    accessibilityEventSender.setVirtualViewId(idx);
    postDelayed(accessibilityEventSender, TIMEOUT_SEND_ACCESSIBILITY_EVENT);
  }

  /** Command for sending an accessibility event. */
  private class AccessibilityEventSender implements Runnable {
    int virtualViewId = -1;

    void setVirtualViewId(int virtualViewId) {
      this.virtualViewId = virtualViewId;
    }

    @Override
    public void run() {
      accessibilityHelper.sendEventForVirtualView(
          virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
    }
  }

  @Override
  protected Parcelable onSaveInstanceState() {
    Parcelable superState = super.onSaveInstanceState();
    SliderState sliderState = new SliderState(superState);
    sliderState.valueFrom = valueFrom;
    sliderState.valueTo = valueTo;
    sliderState.values = new ArrayList<>(values);
    sliderState.stepSize = stepSize;
    sliderState.hasFocus = hasFocus();
    return sliderState;
  }

  @Override
  protected void onRestoreInstanceState(Parcelable state) {
    SliderState sliderState = (SliderState) state;
    super.onRestoreInstanceState(sliderState.getSuperState());

    valueFrom = sliderState.valueFrom;
    valueTo = sliderState.valueTo;
    setValuesInternal(sliderState.values);
    stepSize = sliderState.stepSize;
    if (sliderState.hasFocus) {
      requestFocus();
    }
  }

  static class SliderState extends BaseSavedState {

    float valueFrom;
    float valueTo;
    ArrayList<Float> values;
    float stepSize;
    boolean hasFocus;

    public static final Creator<SliderState> CREATOR =
        new Creator<SliderState>() {

          @NonNull
          @Override
          public SliderState createFromParcel(@NonNull Parcel source) {
            return new SliderState(source);
          }

          @NonNull
          @Override
          public SliderState[] newArray(int size) {
            return new SliderState[size];
          }
        };

    SliderState(Parcelable superState) {
      super(superState);
    }

    private SliderState(@NonNull Parcel source) {
      super(source);
      valueFrom = source.readFloat();
      valueTo = source.readFloat();
      values = new ArrayList<>();
      source.readList(values, Float.class.getClassLoader());
      stepSize = source.readFloat();
      hasFocus = source.createBooleanArray()[0];
    }

    @Override
    public void writeToParcel(@NonNull Parcel dest, int flags) {
      super.writeToParcel(dest, flags);
      dest.writeFloat(valueFrom);
      dest.writeFloat(valueTo);
      dest.writeList(values);
      dest.writeFloat(stepSize);
      boolean[] booleans = new boolean[1];
      booleans[0] = hasFocus;
      dest.writeBooleanArray(booleans);
    }
  }

  void updateBoundsForVirtualViewId(int virtualViewId, Rect virtualViewBounds) {
    int x = trackSidePadding + (int) (normalizeValue(getValues().get(virtualViewId)) * trackWidth);
    int y = calculateTrackCenter();
    int touchTargetRadius =
        (thumbRadius > minTouchTargetSize ? thumbRadius : minTouchTargetSize) / 2;

    virtualViewBounds.set(
        x - touchTargetRadius, y - touchTargetRadius, x + touchTargetRadius, y + touchTargetRadius);
  }

  private static class AccessibilityHelper extends ExploreByTouchHelper {

    private final BaseSlider<?, ?, ?> slider;
    final Rect virtualViewBounds = new Rect();

    AccessibilityHelper(BaseSlider<?, ?, ?> slider) {
      super(slider);
      this.slider = slider;
    }

    @Override
    protected int getVirtualViewAt(float x, float y) {
      for (int i = 0; i < slider.getValues().size(); i++) {
        slider.updateBoundsForVirtualViewId(i, virtualViewBounds);
        if (virtualViewBounds.contains((int) x, (int) y)) {
          return i;
        }
      }

      return ExploreByTouchHelper.HOST_ID;
    }

    @Override
    protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
      for (int i = 0; i < slider.getValues().size(); i++) {
        virtualViewIds.add(i);
      }
    }

    @Override
    protected void onPopulateNodeForVirtualView(
        int virtualViewId, AccessibilityNodeInfoCompat info) {

      info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_PROGRESS);

      List<Float> values = slider.getValues();
      final float value = values.get(virtualViewId);
      float valueFrom = slider.getValueFrom();
      float valueTo = slider.getValueTo();

      if (slider.isEnabled()) {
        if (value > valueFrom) {
          info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
        }
        if (value < valueTo) {
          info.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
        }
      }

      info.setRangeInfo(RangeInfoCompat.obtain(RANGE_TYPE_FLOAT, valueFrom, valueTo, value));
      info.setClassName(SeekBar.class.getName());
      StringBuilder contentDescription = new StringBuilder();
      // Add the content description of the slider.
      if (slider.getContentDescription() != null) {
        contentDescription.append(slider.getContentDescription()).append(",");
      }
      // Add the range/value to the content description.
      String verbalValue = slider.formatValue(value);
      String verbalValueType = slider.getContext().getString(R.string.material_slider_value);
      if (values.size() > 1) {
        verbalValueType = startOrEndDescription(virtualViewId);
      }
      contentDescription.append(String.format(Locale.US, "%s, %s", verbalValueType, verbalValue));
      info.setContentDescription(contentDescription.toString());

      slider.updateBoundsForVirtualViewId(virtualViewId, virtualViewBounds);
      info.setBoundsInParent(virtualViewBounds);
    }

    @NonNull
    private String startOrEndDescription(int virtualViewId) {
      List<Float> values = slider.getValues();
      if (virtualViewId == values.size() - 1) {
        return slider.getContext().getString(R.string.material_slider_range_end);
      }

      if (virtualViewId == 0) {
        return slider.getContext().getString(R.string.material_slider_range_start);
      }

      return "";
    }

    @Override
    protected boolean onPerformActionForVirtualView(
        int virtualViewId, int action, Bundle arguments) {
      if (!slider.isEnabled()) {
        return false;
      }

      switch (action) {
        case android.R.id.accessibilityActionSetProgress:
          {
            if (arguments == null
                || !arguments.containsKey(
                    AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE)) {
              return false;
            }
            float value =
                arguments.getFloat(AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE);
            if (slider.snapThumbToValue(virtualViewId, value)) {
              slider.updateHaloHotspot();
              slider.postInvalidate();
              invalidateVirtualView(virtualViewId);
              return true;
            }
            return false;
          }
        case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD:
        case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD:
          {
            float increment = slider.calculateStepIncrement(20);
            if (action == AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD) {
              increment = -increment;
            }

            // Swap the increment if we're in RTL.
            if (slider.isRtl()) {
              increment = -increment;
            }

            List<Float> values = slider.getValues();
            float clamped =
                clamp(
                    values.get(virtualViewId) + increment,
                    slider.getValueFrom(),
                    slider.getValueTo());
            if (slider.snapThumbToValue(virtualViewId, clamped)) {
              slider.updateHaloHotspot();
              slider.postInvalidate();
              invalidateVirtualView(virtualViewId);
              return true;
            }
            return false;
          }
        default:
          return false;
      }
    }
  }
}
