/* * Copyright (c) Gustavo Claramunt (AnderWeb) 2014. * * 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.marshalchen.common.uimodule.discreteseekbar; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.Nullable; import android.support.v4.graphics.drawable.DrawableCompat; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.util.TypedValue; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewParent; import com.marshalchen.common.uimodule.R; import com.marshalchen.common.uimodule.discreteseekbar.internal.PopupIndicator; import com.marshalchen.common.uimodule.discreteseekbar.internal.compat.AnimatorCompat; import com.marshalchen.common.uimodule.discreteseekbar.internal.compat.SeekBarCompat; import com.marshalchen.common.uimodule.discreteseekbar.internal.drawable.MarkerDrawable; import com.marshalchen.common.uimodule.discreteseekbar.internal.drawable.ThumbDrawable; import com.marshalchen.common.uimodule.discreteseekbar.internal.drawable.TrackRectDrawable; import java.util.Formatter; import java.util.Locale; public class DiscreteSeekBar extends View { /** * Interface to propagate seekbar change event */ public interface OnProgressChangeListener { /** * When the {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar} value changes * * @param seekBar The DiscreteSeekBar * @param value the new value * @param fromUser if the change was made from the user or not (i.e. the developer calling {@link #setProgress(int)} */ public void onProgressChanged(DiscreteSeekBar seekBar, int value, boolean fromUser); } /** * Interface to transform the current internal value of this DiscreteSeekBar to anther one for the visualization. * <p/> * This will be used on the floating bubble to diaplay a different value if needed. * <p/> * Using this in conjunction with {@link #setIndicatorFormatter(String)} you will be able to manipulate the * value seen by the user * * @see #setIndicatorFormatter(String) * @see #setNumericTransformer(com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar.NumericTransformer) */ public interface NumericTransformer { /** * Return the desired value to be shown to the user. * * @param value * @return */ public int transform(int value); } private static class DefaultNumericTransformer implements NumericTransformer { @Override public int transform(int value) { return value; } } private static final boolean isLollipopOrGreater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; //We want to always use a formatter so the indicator numbers are "translated" to specific locales. private static final String DEFAULT_FORMATTER = "%d"; private static final int PRESSED_STATE = android.R.attr.state_pressed; private static final int FOCUSED_STATE = android.R.attr.state_focused; private static final int PROGRESS_ANIMATION_DURATION = 250; private static final int INDICATOR_DELAY_FOR_TAPS = 150; private ThumbDrawable mThumb; private Drawable mTrack; private Drawable mScrubber; private Drawable mRipple; private int mTrackHeight; private int mScrubberHeight; private int mAddedTouchBounds; private int mMax; private int mMin; private int mValue; private int mKeyProgressIncrement = 1; private boolean mMirrorForRtl = false; private boolean mAllowTrackClick = true; //We use our own Formatter to avoid creating new instances on every progress change Formatter mFormatter; private String mIndicatorFormatter; private NumericTransformer mNumericTransformer; private OnProgressChangeListener mPublicChangeListener; private boolean mIsDragging; private int mDraggOffset; private Rect mInvalidateRect = new Rect(); private Rect mTempRect = new Rect(); private PopupIndicator mIndicator; private AnimatorCompat mPositionAnimator; private float mAnimationPosition; private int mAnimationTarget; private float mDownX; private float mTouchSlop; public DiscreteSeekBar(Context context) { this(context, null); } public DiscreteSeekBar(Context context, AttributeSet attrs) { this(context, attrs, R.style.DefaultSeekBar); } public DiscreteSeekBar(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); setFocusable(true); setWillNotDraw(false); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); float density = context.getResources().getDisplayMetrics().density; mTrackHeight = (int) (1 * density); mScrubberHeight = (int) (4 * density); int thumbSize = (int) (density * ThumbDrawable.DEFAULT_SIZE_DP); //Extra pixels for a touch area of 48dp int touchBounds = (int) (density * 32); mAddedTouchBounds = (touchBounds - thumbSize) / 2; TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.DiscreteSeekBar, R.attr.discreteSeekBarStyle, defStyle); int max = 100; int min = 0; int value = 0; mMirrorForRtl = a.getBoolean(R.styleable.DiscreteSeekBar_dsb_mirrorForRtl, mMirrorForRtl); mAllowTrackClick = a.getBoolean(R.styleable.DiscreteSeekBar_dsb_allowTrackClickToDrag, mAllowTrackClick); int indexMax = R.styleable.DiscreteSeekBar_dsb_max; int indexMin = R.styleable.DiscreteSeekBar_dsb_min; int indexValue = R.styleable.DiscreteSeekBar_dsb_value; final TypedValue out = new TypedValue(); //Not sure why, but we wanted to be able to use dimensions here... if (a.getValue(indexMax, out)) { if (out.type == TypedValue.TYPE_DIMENSION) { max = a.getDimensionPixelSize(indexMax, max); } else { max = a.getInteger(indexMax, max); } } if (a.getValue(indexMin, out)) { if (out.type == TypedValue.TYPE_DIMENSION) { min = a.getDimensionPixelSize(indexMin, min); } else { min = a.getInteger(indexMin, min); } } if (a.getValue(indexValue, out)) { if (out.type == TypedValue.TYPE_DIMENSION) { value = a.getDimensionPixelSize(indexValue, value); } else { value = a.getInteger(indexValue, value); } } mMin = min; mMax = Math.max(min + 1, max); mValue = Math.max(min, Math.min(max, value)); updateKeyboardRange(); mIndicatorFormatter = a.getString(R.styleable.DiscreteSeekBar_dsb_indicatorFormatter); ColorStateList trackColor = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_trackColor); ColorStateList progressColor = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_progressColor); ColorStateList rippleColor = a.getColorStateList(R.styleable.DiscreteSeekBar_dsb_rippleColor); boolean editMode = isInEditMode(); if (editMode && rippleColor == null) { rippleColor = new ColorStateList(new int[][]{new int[]{}}, new int[]{Color.DKGRAY}); } if(editMode && trackColor == null) { trackColor = new ColorStateList(new int[][]{new int[]{}}, new int[]{Color.GRAY}); } if(editMode && progressColor == null) { progressColor = new ColorStateList(new int[][]{new int[]{}}, new int[]{0xff009688}); } mRipple = SeekBarCompat.getRipple(rippleColor); if (isLollipopOrGreater) { SeekBarCompat.setBackground(this, mRipple); } else { mRipple.setCallback(this); } TrackRectDrawable shapeDrawable = new TrackRectDrawable(trackColor); mTrack = shapeDrawable; mTrack.setCallback(this); shapeDrawable = new TrackRectDrawable(progressColor); mScrubber = shapeDrawable; mScrubber.setCallback(this); ThumbDrawable thumbDrawable = new ThumbDrawable(progressColor, thumbSize); mThumb = thumbDrawable; mThumb.setCallback(this); mThumb.setBounds(0, 0, mThumb.getIntrinsicWidth(), mThumb.getIntrinsicHeight()); if (!editMode) { mIndicator = new PopupIndicator(context, attrs, defStyle, convertValueToMessage(mMax)); mIndicator.setValue(convertValueToMessage(mValue)); mIndicator.setListener(mFloaterListener); } a.recycle(); setNumericTransformer(new DefaultNumericTransformer()); } /** * Sets the current Indicator formatter string * * @param formatter * @see String#format(String, Object...) * @see #setNumericTransformer(com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar.NumericTransformer) */ public void setIndicatorFormatter(@Nullable String formatter) { mIndicatorFormatter = formatter; updateProgressMessage(mValue); } /** * Sets the current {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar.NumericTransformer} * * @param transformer * @see #getNumericTransformer() */ public void setNumericTransformer(@Nullable NumericTransformer transformer) { mNumericTransformer = transformer != null ? transformer : new DefaultNumericTransformer(); //We need to refresh the PopupIndivator view if (!isInEditMode()) { mIndicator.updateSizes(convertValueToMessage(mNumericTransformer.transform(mMax))); } updateProgressMessage(mValue); } /** * Retrieves the current {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar.NumericTransformer} * * @return NumericTransformer * @see #setNumericTransformer */ public NumericTransformer getNumericTransformer() { return mNumericTransformer; } /** * Sets the maximum value for this DiscreteSeekBar * if the supplied argument is smaller than the Current MIN value, * the MIN value will be set to MAX-1 * <p/> * <p> * Also if the current progress is out of the new range, it will be set to MIN * </p> * * @param max * @see #setMin(int) * @see #setProgress(int) */ public void setMax(int max) { mMax = max; if (mMax < mMin) { setMin(mMax - 1); } updateKeyboardRange(); if (mValue < mMin || mValue > mMax) { setProgress(mMin); } } public int getMax() { return mMax; } /** * Sets the minimum value for this DiscreteSeekBar * if the supplied argument is bigger than the Current MAX value, * the MAX value will be set to MIN+1 * <p> * Also if the current progress is out of the new range, it will be set to MIN * </p> * * @param min * @see #setMax(int) * @see #setProgress(int) */ public void setMin(int min) { mMin = min; if (mMin > mMax) { setMax(mMin + 1); } updateKeyboardRange(); if (mValue < mMin || mValue > mMax) { setProgress(mMin); } } public int getMin() { return mMin; } /** * Sets the current progress for this DiscreteSeekBar * The supplied argument will be capped to the current MIN-MAX range * * @param progress * @see #setMax(int) * @see #setMin(int) */ public void setProgress(int progress) { setProgress(progress, false); } private void setProgress(int value, boolean fromUser) { value = Math.max(mMin, Math.min(mMax, value)); if (isAnimationRunning()) { mPositionAnimator.cancel(); } if (mValue != value) { notifyProgress(value, fromUser); mValue = value; updateProgressMessage(value); updateThumbPosFromCurrentProgress(); } } /** * Get the current progress * * @return the current progress :-P */ public int getProgress() { return mValue; } /** * Sets a listener to receive notifications of changes to the DiscreteSeekBar's progress level. Also * provides notifications of when the DiscreteSeekBar shows/hides the bubble indicator. * * @param listener The seek bar notification listener * @see com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar.OnProgressChangeListener */ public void setOnProgressChangeListener(OnProgressChangeListener listener) { mPublicChangeListener = listener; } private void notifyProgress(int value, boolean fromUser) { if (mPublicChangeListener != null) { mPublicChangeListener.onProgressChanged(DiscreteSeekBar.this, value, fromUser); } onValueChanged(value); } private void notifyBubble(boolean open) { if (open) { onShowBubble(); } else { onHideBubble(); } } /** * When the {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar} enters pressed or focused state * the bubble with the value will be shown, and this method called * <p> * Subclasses may override this to add functionality around this event * </p> */ protected void onShowBubble() { } /** * When the {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar} exits pressed or focused state * the bubble with the value will be hidden, and this method called * <p> * Subclasses may override this to add functionality around this event * </p> */ protected void onHideBubble() { } /** * When the {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar} value changes this method is called * <p> * Subclasses may override this to add functionality around this event * without having to specify a {@link com.marshalchen.common.uimodule.discreteseekbar.DiscreteSeekBar.OnProgressChangeListener} * </p> */ protected void onValueChanged(int value) { } private void updateKeyboardRange() { int range = mMax - mMin; if ((mKeyProgressIncrement == 0) || (range / mKeyProgressIncrement > 20)) { // It will take the user too long to change this via keys, change it // to something more reasonable mKeyProgressIncrement = Math.max(1, Math.round((float) range / 20)); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthSize = MeasureSpec.getSize(widthMeasureSpec); int height = mThumb.getIntrinsicHeight() + getPaddingTop() + getPaddingBottom(); height += (mAddedTouchBounds * 2); setMeasuredDimension(widthSize, height); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); if (changed) { removeCallbacks(mShowIndicatorRunnable); if (!isInEditMode()) { mIndicator.dismissComplete(); } updateFromDrawableState(); } } @Override public void scheduleDrawable(Drawable who, Runnable what, long when) { super.scheduleDrawable(who, what, when); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); int thumbWidth = mThumb.getIntrinsicWidth(); int thumbHeight = mThumb.getIntrinsicHeight(); int addedThumb = mAddedTouchBounds; int halfThumb = thumbWidth / 2; int paddingLeft = getPaddingLeft() + addedThumb; int paddingRight = getPaddingRight(); int bottom = getHeight() - getPaddingBottom() - addedThumb; mThumb.setBounds(paddingLeft, bottom - thumbHeight, paddingLeft + thumbWidth, bottom); int trackHeight = Math.max(mTrackHeight / 2, 1); mTrack.setBounds(paddingLeft + halfThumb, bottom - halfThumb - trackHeight, getWidth() - halfThumb - paddingRight - addedThumb, bottom - halfThumb + trackHeight); int scrubberHeight = Math.max(mScrubberHeight / 2, 2); mScrubber.setBounds(paddingLeft + halfThumb, bottom - halfThumb - scrubberHeight, paddingLeft + halfThumb, bottom - halfThumb + scrubberHeight); //Update the thumb position after size changed updateThumbPosFromCurrentProgress(); } @Override protected synchronized void onDraw(Canvas canvas) { if (!isLollipopOrGreater) { mRipple.draw(canvas); } super.onDraw(canvas); mTrack.draw(canvas); mScrubber.draw(canvas); mThumb.draw(canvas); } @Override protected void drawableStateChanged() { super.drawableStateChanged(); updateFromDrawableState(); } private void updateFromDrawableState() { int[] state = getDrawableState(); boolean focused = false; boolean pressed = false; for (int i : state) { if (i == FOCUSED_STATE) { focused = true; } else if (i == PRESSED_STATE) { pressed = true; } } if (isEnabled() && (focused || pressed)) { //We want to add a small delay here to avoid //poping in/out on simple taps removeCallbacks(mShowIndicatorRunnable); postDelayed(mShowIndicatorRunnable, INDICATOR_DELAY_FOR_TAPS); } else { hideFloater(); } mThumb.setState(state); mTrack.setState(state); mScrubber.setState(state); mRipple.setState(state); } private void updateProgressMessage(int value) { if (!isInEditMode()) { mIndicator.setValue(convertValueToMessage(mNumericTransformer.transform(value))); } } private String convertValueToMessage(int value) { String format = mIndicatorFormatter != null ? mIndicatorFormatter : DEFAULT_FORMATTER; if (mFormatter == null || mFormatter.locale().equals(Locale.getDefault())) { int bufferSize = format.length() + String.valueOf(mMax).length(); mFormatter = new Formatter(new StringBuilder(bufferSize), Locale.getDefault()); } return mFormatter.format(format, value).toString(); } @Override public boolean onTouchEvent(MotionEvent event) { if (!isEnabled()) { return false; } int actionMasked = MotionEventCompat.getActionMasked(event); switch (actionMasked) { case MotionEvent.ACTION_DOWN: mDownX = event.getX(); startDragging(event, isInScrollingContainer()); break; case MotionEvent.ACTION_MOVE: if (isDragging()) { updateDragging(event); } else { final float x = event.getX(); if (Math.abs(x - mDownX) > mTouchSlop) { startDragging(event, false); } } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: stopDragging(); break; } return true; } private boolean isInScrollingContainer() { return SeekBarCompat.isInScrollingContainer(getParent()); } private boolean startDragging(MotionEvent ev, boolean ignoreTrackIfInScrollContainer) { final Rect bounds = mTempRect; mThumb.copyBounds(bounds); //Grow the current thumb rect for a bigger touch area bounds.inset(-mAddedTouchBounds, -mAddedTouchBounds); mIsDragging = (bounds.contains((int) ev.getX(), (int) ev.getY())); if (!mIsDragging && mAllowTrackClick && !ignoreTrackIfInScrollContainer) { //If the user clicked outside the thumb, we compute the current position //and force an immediate drag to it. mIsDragging = true; mDraggOffset = (bounds.width() / 2) - mAddedTouchBounds; updateDragging(ev); //As the thumb may have moved, get the bounds again mThumb.copyBounds(bounds); bounds.inset(-mAddedTouchBounds, -mAddedTouchBounds); } if (mIsDragging) { setPressed(true); attemptClaimDrag(); setHotspot(ev.getX(), ev.getY()); mDraggOffset = (int) (ev.getX() - bounds.left - mAddedTouchBounds); } return mIsDragging; } private boolean isDragging() { return mIsDragging; } private void stopDragging() { mIsDragging = false; setPressed(false); } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { //TODO: Should we reverse the keys for RTL? The framework's SeekBar does NOT.... boolean handled = false; if (isEnabled()) { int progress = getAnimatedProgress(); switch (keyCode) { case KeyEvent.KEYCODE_DPAD_LEFT: handled = true; if (progress <= mMin) break; animateSetProgress(progress - mKeyProgressIncrement); break; case KeyEvent.KEYCODE_DPAD_RIGHT: handled = true; if (progress >= mMax) break; animateSetProgress(progress + mKeyProgressIncrement); break; } } return handled || super.onKeyDown(keyCode, event); } private int getAnimatedProgress() { return isAnimationRunning() ? getAnimationTarget() : mValue; } boolean isAnimationRunning() { return mPositionAnimator != null && mPositionAnimator.isRunning(); } void animateSetProgress(int progress) { final float curProgress = isAnimationRunning() ? getAnimationPosition() : getProgress(); if (progress < mMin) { progress = mMin; } else if (progress > mMax) { progress = mMax; } //setProgressValueOnly(progress); if (mPositionAnimator != null) { mPositionAnimator.cancel(); } mAnimationTarget = progress; mPositionAnimator = AnimatorCompat.create(curProgress, progress, new AnimatorCompat.AnimationFrameUpdateListener() { @Override public void onAnimationFrame(float currentValue) { setAnimationPosition(currentValue); } }); mPositionAnimator.setDuration(PROGRESS_ANIMATION_DURATION); mPositionAnimator.start(); } private int getAnimationTarget() { return mAnimationTarget; } void setAnimationPosition(float position) { mAnimationPosition = position; float currentScale = (position - mMin) / (float) (mMax - mMin); updateProgressFromAnimation(currentScale); } float getAnimationPosition() { return mAnimationPosition; } private void updateDragging(MotionEvent ev) { setHotspot(ev.getX(), ev.getY()); int x = (int) ev.getX(); Rect oldBounds = mThumb.getBounds(); int halfThumb = oldBounds.width() / 2; int addedThumb = mAddedTouchBounds; int newX = x - mDraggOffset + halfThumb; int left = getPaddingLeft() + halfThumb + addedThumb; int right = getWidth() - (getPaddingRight() + halfThumb + addedThumb); if (newX < left) { newX = left; } else if (newX > right) { newX = right; } int available = right - left; float scale = (float) (newX - left) / (float) available; if (isRtl()) { scale = 1f - scale; } int progress = Math.round((scale * (mMax - mMin)) + mMin); setProgress(progress, true); } private void updateProgressFromAnimation(float scale) { Rect bounds = mThumb.getBounds(); int halfThumb = bounds.width() / 2; int addedThumb = mAddedTouchBounds; int left = getPaddingLeft() + halfThumb + addedThumb; int right = getWidth() - (getPaddingRight() + halfThumb + addedThumb); int available = right - left; int progress = Math.round((scale * (mMax - mMin)) + mMin); //we don't want to just call setProgress here to avoid the animation being cancelled, //and this position is not bound to a real progress value but interpolated if (progress != getProgress()) { mValue = progress; notifyProgress(mValue, true); updateProgressMessage(progress); } final int thumbPos = (int) (scale * available + 0.5f); updateThumbPos(thumbPos); } private void updateThumbPosFromCurrentProgress() { int thumbWidth = mThumb.getIntrinsicWidth(); int addedThumb = mAddedTouchBounds; int halfThumb = thumbWidth / 2; float scaleDraw = (mValue - mMin) / (float) (mMax - mMin); //This doesn't matter if RTL, as we just need the "avaiable" area int left = getPaddingLeft() + halfThumb + addedThumb; int right = getWidth() - (getPaddingRight() + halfThumb + addedThumb); int available = right - left; final int thumbPos = (int) (scaleDraw * available + 0.5f); updateThumbPos(thumbPos); } private void updateThumbPos(int posX) { int thumbWidth = mThumb.getIntrinsicWidth(); int halfThumb = thumbWidth / 2; int start; if (isRtl()) { start = getWidth() - getPaddingRight() - mAddedTouchBounds; posX = start - posX - thumbWidth; } else { start = getPaddingLeft() + mAddedTouchBounds; posX = start + posX; } mThumb.copyBounds(mInvalidateRect); mThumb.setBounds(posX, mInvalidateRect.top, posX + thumbWidth, mInvalidateRect.bottom); if (isRtl()) { mScrubber.getBounds().right = start - halfThumb; mScrubber.getBounds().left = posX + halfThumb; } else { mScrubber.getBounds().left = start + halfThumb; mScrubber.getBounds().right = posX + halfThumb; } final Rect finalBounds = mTempRect; mThumb.copyBounds(finalBounds); if(!isInEditMode()) { mIndicator.move(finalBounds.centerX()); } mInvalidateRect.inset(-mAddedTouchBounds, -mAddedTouchBounds); finalBounds.inset(-mAddedTouchBounds, -mAddedTouchBounds); mInvalidateRect.union(finalBounds); SeekBarCompat.setHotspotBounds(mRipple, finalBounds.left, finalBounds.top, finalBounds.right, finalBounds.bottom); invalidate(mInvalidateRect); } private void setHotspot(float x, float y) { DrawableCompat.setHotspot(mRipple, x, y); } @Override protected boolean verifyDrawable(Drawable who) { return who == mThumb || who == mTrack || who == mScrubber || who == mRipple || super.verifyDrawable(who); } private void attemptClaimDrag() { ViewParent parent = getParent(); if (parent != null) { parent.requestDisallowInterceptTouchEvent(true); } } private Runnable mShowIndicatorRunnable = new Runnable() { @Override public void run() { showFloater(); } }; private void showFloater() { if(!isInEditMode()) { mThumb.animateToPressed(); mIndicator.showIndicator(this, mThumb.getBounds()); notifyBubble(true); } } private void hideFloater() { removeCallbacks(mShowIndicatorRunnable); if(!isInEditMode()) { mIndicator.dismiss(); notifyBubble(false); } } private MarkerDrawable.MarkerAnimationListener mFloaterListener = new MarkerDrawable.MarkerAnimationListener() { @Override public void onClosingComplete() { mThumb.animateToNormal(); } @Override public void onOpeningComplete() { } }; @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); removeCallbacks(mShowIndicatorRunnable); if(!isInEditMode()) { mIndicator.dismissComplete(); } } public boolean isRtl() { return (ViewCompat.getLayoutDirection(this) == LAYOUT_DIRECTION_RTL) && mMirrorForRtl; } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); CustomState state = new CustomState(superState); state.progress = getProgress(); state.max = mMax; state.min = mMin; return state; } @Override protected void onRestoreInstanceState(Parcelable state) { if (state == null || !state.getClass().equals(CustomState.class)) { super.onRestoreInstanceState(state); return; } CustomState customState = (CustomState) state; setMin(customState.min); setMax(customState.max); setProgress(customState.progress, false); super.onRestoreInstanceState(customState.getSuperState()); } static class CustomState extends BaseSavedState { private int progress; private int max; private int min; public CustomState(Parcel source) { super(source); progress = source.readInt(); max = source.readInt(); min = source.readInt(); } public CustomState(Parcelable superState) { super(superState); } @Override public void writeToParcel(Parcel outcoming, int flags) { super.writeToParcel(outcoming, flags); outcoming.writeInt(progress); outcoming.writeInt(max); outcoming.writeInt(min); } public static final Creator<CustomState> CREATOR = new Creator<CustomState>() { @Override public CustomState[] newArray(int size) { return new CustomState[size]; } @Override public CustomState createFromParcel(Parcel incoming) { return new CustomState(incoming); } }; } }