造轮子:滚轮选择器实现及原理解析(源码)

Android · 2023-07-21 · 624 人浏览
造轮子:滚轮选择器实现及原理解析(源码)

系列文章
造轮子:滚轮选择器实现及原理解析(一)
造轮子:滚轮选择器实现及原理解析(二)
造轮子:滚轮选择器实现及原理解析(三)
造轮子:滚轮选择器实现及原理解析(源码)

VerticalPicker

public class VerticalPicker extends View {

    /**
     * 3个状态,常规,惯性滚动,滚动
     */
    public static final int SCROLL_STATE_NORMAL = 0;
    public static final int SCROLL_STATE_FLING = 1;
    public static final int SCROLL_STATE_SCROLLING = 2;
    protected Context context;
    /**
     * 轮盘数据源
     */
    protected String[] data = new String[]{};
    protected Paint paint;
    /**
     * 当前滚动距离
     */
    protected float curY;
    /**
     * 每个item默认状态下的高度,item不在中心时该高度会被缩放
     */
    protected int itemHeight;
    /**
     * 单边最大显示个数(包含中心item)
     */
    protected int showCount;
    /**
     * 边缘item最小缩放值
     */
    protected float minScale;
    /**
     * 边缘item最小透明度
     */
    protected float minAlpha;
    /**
     * 是否循环显示
     */
    protected boolean isLoop;
    protected int textColorId;
    protected int textSize;
    protected int width;
    protected int height;
    protected float lastY;
    protected int scrollState;

    /**
     * 当前选中position
     */
    protected int curPosition;
    protected VelocityTracker velocityTracker = VelocityTracker.obtain();
    protected Scroller scroller;
    protected PickerChangeListener pickerChangeListener;
    private float startY;
    private long startTime;
    private final ValueAnimatorManager manager;

    public VerticalPicker(Context context) {
        this(context, null);
    }

    public VerticalPicker(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public VerticalPicker(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public VerticalPicker(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        this.context = context;
        init(attrs, defStyleAttr, defStyleRes);
        manager = new PickerAnimManager((key, newValue) -> {
            if (TextUtils.equals(key, KEY_PICKER_ADSORB_ANIM)) {
                curY = (float) newValue;
                invalidate();
            } else if (TextUtils.equals(key, KEY_PICKER_SCROLL_ANIM)) {
                curY = adjustingY((float) newValue);
                invalidate();
            }
        });
    }

    protected void init(@Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        // 字重500,Medium效果
        paint.setFakeBoldText(true);
        scroller = new Scroller(context, new DecelerateInterpolator());
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.Picker, defStyleAttr, defStyleRes);
        textSize = typedArray.getDimensionPixelOffset(R.styleable.Picker_android_textSize, 0);
        itemHeight = typedArray.getDimensionPixelOffset(R.styleable.Picker_pickerItemWidth, 0);
        minScale = typedArray.getFloat(R.styleable.Picker_pickerScaleMin, 0);
        minAlpha = typedArray.getFloat(R.styleable.Picker_pickerAlphaMin, 0);
        showCount = typedArray.getInt(R.styleable.Picker_pickerShowCount, 1);
        isLoop = typedArray.getBoolean(R.styleable.Picker_pickerLoop, true);
        paint.setColor(typedArray.getColor(R.styleable.Picker_android_textColor, Color.WHITE));
        paint.setTextSize(textSize);
        typedArray.recycle();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        float y = curY;
        int centerPosition = getCenterShowPosition(y);
        float offsetY = adjustingY(y) - itemHeight * centerPosition;
        int max = centerPosition + showCount;
        // 处于正中心时使两侧显示相同个数item,非中心时下方增加1个
        if (offsetY > 0f) {
            max += 1;
        }
        for (int i = centerPosition - showCount + 1; i < max; i++) {
            drawItem(canvas, i, centerPosition, offsetY);
        }
    }

    /**
     * 获取中心点position,显示在正中心到中心上方itemHeight距离的item会被视为中心
     *
     * @param y 当前Y滚动距离
     * @return 中心点的position
     */
    protected int getCenterShowPosition(float y) {
        float newY = adjustingY(y);
        return (int) (newY / itemHeight);
    }

    /**
     * @param y 滚动距离Y
     * @return 调整后的Y,其范围在0 ~ itemHeight * count 之间,方便计算
     */
    protected float adjustingY(float y) {
        float newY = y;
        if (isLoop) {
            while (newY < 0 || newY > getTotalY()) {
                if (newY < 0) {
                    newY += getTotalY();
                } else {
                    newY -= getTotalY();
                }
            }
        } else {
            // 非循环时不可滚动超出边界
            newY = Math.min((data.length - 1) * itemHeight, Math.max(0, newY));
        }

        return newY;
    }

    /**
     * 调整下标,防止越界
     *
     * @param position 下标
     * @return 调整后的下标
     */
    protected int adjustingPosition(int position) {
        int newPosition = position;
        while (newPosition < 0 || newPosition > data.length - 1) {
            if (newPosition < 0) {
                newPosition += data.length;
            } else {
                newPosition -= data.length;
            }
        }
        return newPosition;
    }

    /**
     * 获取理论上的Y整体高度偏移,作为计算参考。
     *
     * @return 整体Y偏移量
     */
    protected float getTotalY() {
        return data.length * itemHeight;
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        width = right - left;
        height = bottom - top;
    }

    /**
     * 绘制每个item
     *
     * @param canvas         画布
     * @param position       当前position,可能为负数
     * @param centerPosition 中心item的position
     * @param offsetY        中心item的偏移值
     */
    protected void drawItem(Canvas canvas, int position, int centerPosition, float offsetY) {
        // 不循环显示时忽略超出边界的部分
        if (!isLoop && (position < 0 || position >= data.length)) {
            return;
        }
        String text = data[adjustingPosition(position)];
        float textWidth = paint.measureText(text);
        int count = centerPosition - position;
        float totalOffset = offsetY + count * itemHeight;
        float percent = 1f - Math.abs(totalOffset) / (itemHeight * showCount);
        canvas.save();
        // 先缩放后位移,可以使不同item的间距不同,距离中心越近间距越大
        canvas.scale(minScale + (1 - minScale) * percent, minScale + (1 - minScale) * percent, width / 2f, height / 2f);
        canvas.translate(0, -totalOffset);
        paint.setAlpha((int) (255 * (minAlpha + (1 - minAlpha) * percent)));
        Paint.FontMetrics metrics = paint.getFontMetrics();
        canvas.drawText(text, width / 2f - textWidth / 2f, height / 2f - (metrics.top + metrics.bottom) / 2f, paint);
        canvas.restore();
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!isEnabled()) {
            return false;
        }
        // 触摸时取消滚动和吸附
        stopScroll();
        stopAdsorbAnim();
        // 速度监听
        velocityTracker.addMovement(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 重置状态
                scrollState = SCROLL_STATE_NORMAL;
                lastY = event.getY();
                startY = event.getY();
                startTime = System.currentTimeMillis();
                break;
            case MotionEvent.ACTION_MOVE:
                curY -= event.getY() - lastY;
                // 调整y使其不超出边界
                curY = adjustingY(curY);
                lastY = event.getY();
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                if (Math.abs(startY - event.getY()) < 10 && System.currentTimeMillis() - startTime < 100) {
                    setCenterPosition(getNewPosition(curPosition, startY > height / 2f));
                } else {
                    curY -= event.getY() - lastY;
                    // 调整y使其不超出边界
                    curY = adjustingY(curY);
                    lastY = event.getY();
                    // 计算速度,根据速度决定惯性滚动还是吸附
                    velocityTracker.computeCurrentVelocity(1000, 2000);
                    checkTouch((int) velocityTracker.getXVelocity(), (int) velocityTracker.getYVelocity());
                }
                break;
            default:
                break;
        }

        return true;
    }

    private int getNewPosition(int position, boolean positionUp) {
        int newPosition = positionUp ? position + 1 : position - 1;
        if (isLoop) {
            return adjustingPosition(newPosition);
        }
        return Math.max(0, Math.min(data.length, newPosition));
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        getParent().requestDisallowInterceptTouchEvent(true);
        return super.dispatchTouchEvent(event);
    }

    /**
     * 开始惯性滚动
     *
     * @param xSpeed X轴速度,可为负数
     * @param ySpeed Y轴速度,可为负数
     */
    private void startFling(int xSpeed, int ySpeed) {
        scrollState = SCROLL_STATE_FLING;
        scroller.fling(0, (int) lastY, xSpeed, ySpeed, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        invalidate();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        // 当开始吸附时舍弃计算值
        if (!manager.isAnimRunning(KEY_PICKER_ADSORB_ANIM) && scrollState == SCROLL_STATE_FLING && scroller.computeScrollOffset()) {
            // 速度低于一定值时执行吸附操作
            if (Math.abs(scroller.getCurrVelocity()) > 100) {
                curY -= scroller.getCurrY() - lastY;
                curY = adjustingY(curY);
                lastY = scroller.getCurrY();
                // 不循环时限制滚动到边界,且相同值时不多次触发回调
                if (!isLoop) {
                    int newPosition = -1;
                    if (curY == 0) {
                        newPosition = 0;
                    } else if (curY == itemHeight * (data.length - 1)) {
                        newPosition = data.length - 1;
                    }
                    if (pickerChangeListener != null && newPosition >= 0 && curPosition != newPosition) {
                        curPosition = newPosition;
                        pickerChangeListener.onPickerSelectedChanged(this, curPosition, data[curPosition], true);
                    }
                }
                invalidate();
            } else {
                startAdsorbAnim();
            }
        }
    }

    /**
     * 开始吸附动画
     */
    public void startAdsorbAnim() {
        float y = curY;
        int centerPosition = getCenterShowPosition(y);
        float offsetY = adjustingY(y) - itemHeight * centerPosition;
        int newPosition;
        // 超出一半时吸附到下一个item
        if (offsetY >= itemHeight / 2f) {
            manager.playAnim(KEY_PICKER_ADSORB_ANIM, curY, curY + itemHeight - offsetY);
            newPosition = adjustingPosition(centerPosition + 1);
        } else {
            manager.playAnim(KEY_PICKER_ADSORB_ANIM, curY, curY - offsetY);
            newPosition = adjustingPosition(centerPosition);
        }
        if (pickerChangeListener != null && curPosition != newPosition) {
            curPosition = newPosition;
            pickerChangeListener.onPickerSelectedChanged(this, curPosition, data[curPosition], true);
        }
    }

    /**
     * 停止吸附
     */
    public void stopAdsorbAnim() {
        manager.stopAnim(KEY_PICKER_ADSORB_ANIM);
    }


    /**
     * 开始滚动动画,用于手动指定中心position时使用
     *
     * @param endY 滚动的结束值
     */
    protected void startScroll(float endY) {
        float y = adjustingY(curY);
        scrollState = SCROLL_STATE_SCROLLING;
        int min = 0;
        if (isLoop) {
            // 循环时就近选择最近的滚动方式,上下各延长getTotalY距离
            int normal = (int) (endY - y);
            int less = (int) (endY - y - getTotalY());
            int more = (int) (endY - y + getTotalY());
            if (Math.abs(normal) < Math.abs(less)) {
                min = normal;
            } else {
                min = less;
            }
            if (Math.abs(more) < Math.abs(min)) {
                min = more;
            }
        } else {
            min = (int) (endY - y);
        }
        manager.playAnim(KEY_PICKER_SCROLL_ANIM, y, min + y);
    }

    /**
     * 停止滚动
     */
    private void stopScroll() {
        manager.stopAnim(KEY_PICKER_SCROLL_ANIM);
    }

    /**
     * 根据当前速度决定是否要进行惯性滚动
     *
     * @param speedX X轴速度
     * @param speedY Y轴速度
     */
    protected void checkTouch(int speedX, int speedY) {
        if (Math.abs(speedY) > 100) {
            startFling(speedX, speedY);
        } else {
            startAdsorbAnim();
        }
    }

    /**
     * 设置中心显示的item的下标
     *
     * @param position 目标position
     */
    public void setCenterPosition(int position) {
        setCenterPosition(position, true);
    }

    /**
     * 设置中心显示的item的下标
     *
     * @param position 目标position
     * @param smooth   是否播放动画
     */
    public void setCenterPosition(int position, boolean smooth) {
        stopAdsorbAnim();
        stopScroll();
        scrollState = SCROLL_STATE_NORMAL;
        if (!smooth) {
            curY = itemHeight * position;
            invalidate();
        } else {
            startScroll(itemHeight * position);
        }
        curPosition = position;
        if (pickerChangeListener != null) {
            pickerChangeListener.onPickerSelectedChanged(this, curPosition, data[curPosition], false);
        }
    }

    /**
     * 重新设置数据
     *
     * @param data 新数据
     */
    public void setData(String[] data) {
        this.data = data;
        invalidate();
    }

    /**
     * 设置行高
     *
     * @param itemHeight 行高
     */
    public void setItemHeight(int itemHeight) {
        this.itemHeight = itemHeight;
        invalidate();
    }

    /**
     * 设置单边显示个数(包含中心item)
     *
     * @param showCount 显示个数
     */
    public void setShowCount(int showCount) {
        this.showCount = showCount;
        invalidate();

    }

    /**
     * 设置边缘最小缩放
     *
     * @param minScale 最小缩放
     */
    public void setMinScale(float minScale) {
        this.minScale = minScale;
        invalidate();

    }

    /**
     * 设置边缘最小透明度
     *
     * @param minAlpha 最小透明度
     */
    public void setMinAlpha(float minAlpha) {
        this.minAlpha = minAlpha;
        invalidate();

    }

    /**
     * 设置文字颜色id
     *
     * @param textColorId 文字颜色id
     */
    public void setTextColorId(int textColorId) {
        this.textColorId = textColorId;
        if (textColorId > 0) {
            paint.setColor(context.getColor(textColorId));
        }
        invalidate();
    }

    /**
     * 设置中心文字颜色
     *
     * @param color 颜色值int
     */
    public void setTextColor(int color) {
        this.textColorId = 0;
        paint.setColor(color);
        invalidate();
    }

    /**
     * 设置中心文字大小
     *
     * @param textSize 文字大小
     */
    public void setTextSize(int textSize) {
        this.textSize = textSize;
        invalidate();
    }



    public void updateTheme() {
        if (textColorId > 0) {
            paint.setColor(context.getColor(textColorId));
        }
        invalidate();
    }

    public void setPickerChangeListener(PickerChangeListener pickerChangeListener) {
        this.pickerChangeListener = pickerChangeListener;
    }

    /**
     * 获取当前选中item在数组里的下标
     *
     * @return 当前选中下标
     */
    public int getCurPosition() {
        return curPosition;
    }
}

PickerAnimManager

public class PickerAnimManager extends ValueAnimatorManager {

    public static final String KEY_PICKER_ADSORB_ANIM = "key_picker_adsorb_anim";
    public static final String KEY_PICKER_SCROLL_ANIM = "key_picker_scroll_anim";
    public PickerAnimManager() {
    }

    public PickerAnimManager(AnimListener animListener) {
        super(animListener);
    }

    @Override
    protected ValueAnimator buildAnimator(String key) {
        ValueAnimator animator = null;
        if (TextUtils.equals(key, KEY_PICKER_ADSORB_ANIM)) {
            animator = ValueAnimator.ofFloat(0, 0).setDuration(200);
        } else if (TextUtils.equals(key, KEY_PICKER_SCROLL_ANIM)) {
            animator = ValueAnimator.ofFloat(0, 0).setDuration(300);
        }
        return animator;
    }

}

PickerChangeListener

public interface PickerChangeListener {
    /**
     * onPickerSelectedChanged
     * @param view     当前View本体
     * @param position 中心item的下标
     * @param data     数据
     * @param fromUser 是否用户操作
     */
    void onPickerSelectedChanged(View view, int position, String data, boolean fromUser);
}

ValueAnimatorManager

public abstract class ValueAnimatorManager {
    private AnimListener animListener;
    private final ArraySet<String> autoCancelAnimKeyList;
    private final HashMap<String, ValueAnimator> animatorHashMap;

    public ValueAnimatorManager() {
        autoCancelAnimKeyList = new ArraySet<>();
        animatorHashMap = new HashMap<>();
    }

    public ValueAnimatorManager(AnimListener animListener) {
        this.animListener = animListener;
        autoCancelAnimKeyList = new ArraySet<>();
        animatorHashMap = new HashMap<>();
    }

    protected abstract ValueAnimator buildAnimator(String key);

    public void playAnim(String key, float... args) {
        playAnim(key, null, args);
    }

    public void playAnim(String key, Animator.AnimatorListener listener, float... args) {
        if (!checkExist(key)) {
            return;
        }
        ValueAnimator animator = animatorHashMap.get(key);
        animator.removeAllListeners();
        if (listener != null) {
            animator.addListener(listener);
        }
        animator.setFloatValues(args);
        animator.start();
    }

    public void playAnim(String key, int... args) {
        playAnim(key, null, args);
    }

    public void playAnim(String key, Animator.AnimatorListener listener, int... args) {
        if (!checkExist(key)) {
            return;
        }
        ValueAnimator animator = animatorHashMap.get(key);
        animator.removeAllListeners();
        if (listener != null) {
            animator.addListener(listener);
        }
        animator.setIntValues(args);
        animator.start();
    }

    public void playAnimDelayed(String key, long delayed, float... args) {
        if (!checkExist(key)) {
            return;
        }
        ValueAnimator animator = animatorHashMap.get(key);
        animator.setFloatValues(args);
        animator.setStartDelay(delayed);
    }

    public void stopAnim(String... keys) {
        for (String key : keys) {
            if (!checkExist(key)) {
                continue;
            }
            animatorHashMap.get(key).cancel();
        }

    }

    public void stopAllAnim() {
        for (ValueAnimator animator : animatorHashMap.values()) {
            animator.cancel();
        }
    }

    protected boolean checkExist(String key) {
        ValueAnimator animator = null;
        if (animatorHashMap.containsKey(key)) {
            animator = animatorHashMap.get(key);
        } else {
            animator = buildAnimator(key);
            if (animator == null) {
                return false;
            }
            animator.addUpdateListener(animation -> {
                if (animListener != null) {
                    animListener.onAnimUpdate(key, animation.getAnimatedValue());
                }
            });
            animatorHashMap.put(key, animator);
        }
        return animator != null;
    }

    public boolean isAnimRunning(String key) {
        if (!checkExist(key)) {
            return false;
        }
        return animatorHashMap.get(key).isRunning();

    }

    public void setAnimListener(AnimListener animListener) {
        this.animListener = animListener;
    }


    public void setDuration(String key, long duration) {
        if (!checkExist(key)) {
            return;
        }
        ValueAnimator animator = animatorHashMap.get(key);
        animator.setDuration(duration);
    }

    public interface AnimListener {
        void onAnimUpdate(String key, Object newValue);
    }
}

attr

<declare-styleable name="Picker">
    <!-- 中央文字颜色,会被动态调整透明度 -->
    <attr name="android:textColor"/>
    <!--  中央item文字大小,会被动态缩放 -->
    <attr name="android:textSize"/>
    <!-- 每个item的宽度,随着滚动距离会出现缩放 -->
    <attr name="pickerItemWidth" format="dimension"/>
    <!-- 最边缘的item的缩放极值 -->
    <attr name="pickerScaleMin" format="float" />
    <!-- 最边缘的item的透明度极值 -->
    <attr name="pickerAlphaMin" format="float" />
    <!-- picker的前景,用于设置修饰图,如横线等 -->
    <attr name="pickerForeground" format="reference" />
    <!-- 最大显示item个数(包含中央item),最少为1,且最好不要大于总个数的一半 -->
    <attr name="pickerShowCount" format="integer" />
    <!-- 是否循环展示 -->
    <attr name="pickerLoop" format="boolean" />
</declare-styleable>
android picker 自定义View
Theme Jasmine by Kent Liao