滚轮选择器扩展 - 角度选择器

滚轮选择器扩展 - 角度选择器

先看效果

功能拆分

1. 滚动方向调整为横向

2. 需要显示刻度,且特定位置的刻度加粗加长,并显示对应数字

3. 中心位置显示当前选中刻度的值,且会遮挡住刻度

4. 刻度整体需要有渐变效果,越靠近两边透明度越低,越靠近中心透明度也越低

实现步骤

这里只考虑思路与实现逻辑,及原竖向逻辑的可扩展性,没有封装

如果有感兴趣的可以基于我的代码二次封装

调整方向

这个很简单了,就是手动工作量的问题

将所有涉及X,Y相关的命名及赋值的地方互换widthheight互换即可,完成后可以看到直接变成了横向滚动

逻辑完全相同,只是涉及变量的处理不同

刻度调整

理论上指针指到任意刻度都可悬停并返回对应值,也就是说刻度是真实存在的数据,只是部分刻度不绘制对应的值而已

接下来itemWidth设置为5dp,并且单边可显示个数增大到50,得到以下的效果,注意需要替换原本绘制文本的逻辑为绘制方块

// 在原本绘制文本的地方改为绘制宽6高8的方块

canvas.drawRect(width /2f -3, height/2f - 4, width/2f +3, height/2f +4, linePaint);

装点刻度

对于特定刻度需要使其变得更长,且要有文字提示

// Acitvity

// 传入的数据每隔15设置一个字串进去,当然你也可以自行构建bean对象,使用一个boolean值控制是否是刻度点

String[] data = new String[180];

for (int i = 0; i < 180; i ++) {

    if (i % 15 ==0) {

        data[i] = i + "";

    } else {

        data[i] = "";

    }

}

// AnglePicker.drawItem()

if (!TextUtils.isEmpty(text)) {

    // 存在文字判断为节点,刻度加长加粗

    canvas.drawRect(width /2f -4, height/2f - 10, width/2f +4, height/2f +10, linePaint);

} else {

    canvas.drawRect(width /2f -3, height/2f - 4, width/2f +3, height/2f +4, linePaint);

}

// 按照原逻辑绘制文本,不过不再绘制在中点了,而是向上平移个50像素使其绘制在刻度上方

canvas.translate(0, -50);

canvas.drawText(text, width / 2f - textWidth / 2f, height / 2f - (metrics.top + metrics.bottom) / 2f, paint);

当前值的绘制

中间总有个数字显示当前的刻度,且刻度到中间的时候好像就渐变消失了,先绘制当前值+箭头

protected void drawCenter(Canvas canvas, int centerPosition) {

    // 绘制三角指示器,这里是demo,感兴趣的同学可以换成任意drawable进行绘制

    path.reset();

    path.moveTo(width / 2f - 10, height / 2f - 80);

    path.lineTo(width / 2f + 10, height / 2f - 80);

    path.lineTo(width / 2f , height / 2f - 50);

    path.close();

    linePaint.setAlpha(255);

    canvas.drawPath(path, linePaint);

    Paint.FontMetrics metrics = paint.getFontMetrics();

    String text = "";

    text = adjustingPosition(centerPosition) + "";

    // 比正常文字大出1.5倍绘制

    paint.setTextSize(textSize * 1.5f);

    paint.setAlpha(255);

    float textWidth = paint.measureText(text);

    // 绘制在屏幕正中心

    canvas.drawText(text, width / 2f - textWidth / 2f, height / 2f - (metrics.top + metrics.bottom) / 2f, paint);

}

重叠处理

这里中间的数字和刻度重叠了,需要简单处理下

1. 重叠部分不绘制

这个很简单,大文字在靠近的时候直接不绘制即可,我这里限制距离8个刻度以内不绘制

// 文字靠近中间8格以内不显示,防止重叠

if (Math.abs(position - centerPosition) > 8) {

    canvas.drawText(text, width / 2f - textWidth / 2f, height / 2f - (metrics.top + metrics.bottom) / 2f, paint);

}

2. 渐变效果

刨除中间的4格区域内的刻度之外,4-10格刻度使用动态计算alpha实现渐变效果,大功告成

有了竖向的基本逻辑加持,实现一个横向的角度指示器其实很简单,只需要改改UI即可

if (Math.abs(position - centerPosition) < 10) {

    // 距离中间最近的10个渐变变化,最中间4个不显示,其余6个等比例alpha

    float lineAlphaPercent = Math.max(0,Math.abs(totalOffset) - (4  itemWidth)) / (6f  itemWidth);

    linePaint.setAlpha((int) (255 * lineAlphaPercent));

} else {

    linePaint.setAlpha((int) (255  (minAlpha + (1 - minAlpha)  percent)));

}

源码

String[] data = new String[180];

for (int i = 0; i < 180; i ++) {

    if (i % 15 ==0) {

        data[i] = i + "";

    } else {

        data[i] = "";

    }

}

((AnglePicker)findViewById(R.id.picker)).setData(data);

   <com.example.myapplication.picker.AnglePicker

        android:id="@+id/picker"

        app:pickerLoop="true"

        app:pickerShowCount="50"

        app:pickerAlphaMin="0.5"

        app:pickerScaleMin="1"

        app:pickerItemWidth="5dp"

        android:textSize="16sp"

        android:textColor="#ffffff"

        android:background="#000000"

        android:layout_width="match_parent"

        android:layout_height="150dp"/>

public class AnglePicker 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 curX;

    /**

     * 每个item默认状态下的高度,item不在中心时该高度会被缩放

     */

    protected int itemWidth;

    /**

     * 单边最大显示个数(包含中心item)

     */

    protected int showCount;

    /**

     * 边缘item最小缩放值

     */

    protected float minScale;

    /**

     * 边缘item最小透明度

     */

    protected float minAlpha;

    /**

     * 是否循环显示

     */

    protected boolean isLoop;

    protected int textSize;

    protected int width;

    protected int height;

    protected float lastX;

    protected int scrollState;

    /**

     * 当前选中position

     */

    protected int curPosition;

    protected VelocityTracker velocityTracker = VelocityTracker.obtain();

    protected Scroller scroller;

    protected PickerChangeListener pickerChangeListener;

    private float startX;

    private long startTime;

    private Paint linePaint;

    private Path path;

    private final ValueAnimatorManager manager;

    public BasePicker(Context context) {

        this(context, null);

    }

    public BasePicker(Context context, @Nullable AttributeSet attrs) {

        this(context, attrs, 0);

    }

    public BasePicker(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {

        this(context, attrs, defStyleAttr, 0);

    }

    public BasePicker(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)) {

                curX = (float) newValue;

                invalidate();

            } else if (TextUtils.equals(key, KEY_PICKER_SCROLL_ANIM)) {

                curX = adjustingX((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);

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

        typedArray.recycle();

        paint.setTextSize(textSize);

        linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);

        linePaint.setStyle(Paint.Style.FILL);

        linePaint.setColor(Color.WHITE);

        path = new Path();

    }

    @Override

    protected void onDraw(Canvas canvas) {

        super.onDraw(canvas);

        float x = curX;

        int centerPosition = getCenterShowPosition(x);

        float offsetX = adjustingX(x) - itemWidth * centerPosition;

        int max = centerPosition + showCount;

        // 处于正中心时使两侧显示相同个数item,非中心时下方增加1个

        if (offsetX > 0f) {

            max += 1;

        }

        for (int i = centerPosition - showCount + 1; i < max; i++) {

            drawItem(canvas, i, centerPosition, offsetX);

        }

        drawCenter(canvas, centerPosition);

    }

    /**

     * 获取中心点position,显示在正中心到中心上方itemWidth距离的item会被视为中心

     *

     * @param x 当前x滚动距离

     * @return 中心点的position

     */

    protected int getCenterShowPosition(float x) {

        float newX = adjustingX(x);

        return (int) (newX / itemWidth);

    }

    /**

     * @param x 滚动距离Y

      @return 调整后的Y,其范围在0 ~ itemWidth  count 之间,方便计算

     */

    protected float adjustingX(float x) {

        float newX = x;

        if (isLoop) {

            while (newX < 0 || newX > getTotalX()) {

                if (newX < 0) {

                    newX += getTotalX();

                } else {

                    newX -= getTotalX();

                }

            }

        } else {

            // 非循环时不可滚动超出边界

            newX = Math.min((data.length - 1) * itemWidth, Math.max(0, newX));

        }

        return newX;

    }

    /**

     * 调整下标,防止越界

     *

     * @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;

    }

    /**

     * 获取理论上的X整体高度偏移,作为计算参考。

     *

     * @return 整体X偏移量

     */

    protected float getTotalX() {

        return data.length * itemWidth;

    }

    @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 offsetX        中心item的偏移值

     */

    protected void drawItem(Canvas canvas, int position, int centerPosition, float offsetX) {

        paint.setTextSize(textSize);

        // 不循环显示时忽略超出边界的部分

        if (!isLoop && (position < 0 || position >= data.length)) {

            return;

        }

        String text = data[adjustingPosition(position)];

        if (text == null) {

            Log.d("aaa", "aaa");

        }

        float textWidth = paint.measureText(text);

        int count = centerPosition - position;

        float totalOffset = offsetX + count * itemWidth;

        float percent = 1f - Math.abs(totalOffset) / (itemWidth * showCount);

        canvas.save();

        // 先缩放后位移,可以使不同item的间距不同,距离中心越近间距越大

        canvas.scale(minScale + (1 - minScale)  percent, minScale + (1 - minScale)  percent, width / 2f, height / 2f);

        canvas.translate(-totalOffset, 0);

        if (Math.abs(position - centerPosition) < 10) {

            // 距离中间最近的10个渐变变化,最中间4个不显示,其余6个等比例alpha

            float lineAlphaPercent = Math.max(0,Math.abs(totalOffset) - (4  itemWidth)) / (6f  itemWidth);

            linePaint.setAlpha((int) (255 * lineAlphaPercent));

        } else {

            linePaint.setAlpha((int) (255  (minAlpha + (1 - minAlpha)  percent)));

        }

        if (!TextUtils.isEmpty(text)) {

            // 存在文字判断为节点,刻度加长加粗

            canvas.drawRect(width /2f -4, height/2f - 10, width/2f +4, height/2f +10, linePaint);

        } else {

            canvas.drawRect(width /2f -3, height/2f - 4, width/2f +3, height/2f +4, linePaint);

        }

        paint.setAlpha((int) (255  (minAlpha + (1 - minAlpha)  percent)));

        Paint.FontMetrics metrics = paint.getFontMetrics();

        canvas.translate(0, -50);

        // 文字靠近中间8格以内不显示,防止重叠

        if (Math.abs(position - centerPosition) > 8) {

            canvas.drawText(text, width / 2f - textWidth / 2f, height / 2f - (metrics.top + metrics.bottom) / 2f, paint);

        }

        canvas.restore();

    }

    protected void drawCenter(Canvas canvas, int centerPosition) {

        // 绘制三角指示器

        path.reset();

        path.moveTo(width / 2f - 10, height / 2f - 80);

        path.lineTo(width / 2f + 10, height / 2f - 80);

        path.lineTo(width / 2f , height / 2f - 50);

        path.close();

        linePaint.setAlpha(255);

        canvas.drawPath(path, linePaint);

        Paint.FontMetrics metrics = paint.getFontMetrics();

        String text = "";

        text = adjustingPosition(centerPosition) + "";

        paint.setTextSize(textSize * 1.5f);

        paint.setAlpha(255);

        float textWidth = paint.measureText(text);

        canvas.drawText(text, width / 2f - textWidth / 2f, height / 2f - (metrics.top + metrics.bottom) / 2f, paint);

    }

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

                lastX = event.getX();

                startX = event.getX();

                startTime = System.currentTimeMillis();

                break;

            case MotionEvent.ACTION_MOVE:

                curX -= event.getX() - lastX;

                // 调整y使其不超出边界

                curX = adjustingX(curX);

                lastX = event.getX();

                invalidate();

                break;

            case MotionEvent.ACTION_UP:

                if (Math.abs(startX - event.getX()) < 10 && System.currentTimeMillis() - startTime < 100) {

                    setCenterPosition(getNewPosition(curPosition, startX > width / 2f));

                } else {

                    curX -= event.getX() - lastX;

                    // 调整y使其不超出边界

                    curX = adjustingX(curX);

                    lastX = event.getX();

                    // 计算速度,根据速度决定惯性滚动还是吸附

                    velocityTracker.computeCurrentVelocity(2000, 3000);

                    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((int) lastX, 0, 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) {

                curX -= scroller.getCurrX() - lastX;

                curX = adjustingX(curX);

                lastX = scroller.getCurrX();

                // 不循环时限制滚动到边界,且相同值时不多次触发回调

                if (!isLoop) {

                    int newPosition = -1;

                    if (curX == 0) {

                        newPosition = 0;

                    } else if (curX == itemWidth * (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 x = curX;

        int centerPosition = getCenterShowPosition(x);

        float offsetX = adjustingX(x) - itemWidth * centerPosition;

        int newPosition;

        // 超出一半时吸附到下一个item

        if (offsetX >= itemWidth / 2f) {

            manager.playAnim(KEY_PICKER_ADSORB_ANIM, curX, curX + itemWidth - offsetX);

            newPosition = adjustingPosition(centerPosition + 1);

        } else {

            manager.playAnim(KEY_PICKER_ADSORB_ANIM, curX, curX - offsetX);

            newPosition = adjustingPosition(centerPosition);

        }

        if ( curPosition != newPosition) {

            curPosition = newPosition;

        }

        if (pickerChangeListener != null) {

            pickerChangeListener.onPickerSelectedChanged(this, curPosition, data[curPosition], true);

        }

    }

    /**

     * 停止吸附

     */

    public void stopAdsorbAnim() {

        manager.stopAnim(KEY_PICKER_ADSORB_ANIM);

    }

    /**

     * 开始滚动动画,用于手动指定中心position时使用

     *

     * @param endX 滚动的结束值

     */

    protected void startScroll(float endX) {

        float x = adjustingX(curX);

        scrollState = SCROLL_STATE_SCROLLING;

        int min = 0;

        if (isLoop) {

            // 循环时就近选择最近的滚动方式,上下各延长getTotalX距离

            int normal = (int) (endX - x);

            int less = (int) (endX - x - getTotalX());

            int more = (int) (endX - x + getTotalX());

            if (Math.abs(normal) < Math.abs(less)) {

                min = normal;

            } else {

                min = less;

            }

            if (Math.abs(more) < Math.abs(min)) {

                min = more;

            }

        } else {

            min = (int) (endX - x);

        }

        manager.playAnim(KEY_PICKER_SCROLL_ANIM, x, min + x);

    }

    /**

     * 停止滚动

     */

    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(speedX) > 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) {

            curX = itemWidth * position;

            invalidate();

        } else {

            startScroll(itemWidth * 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 itemWidth 行高

     */

    public void setitemWidth(int itemWidth) {

        this.itemWidth = itemWidth;

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

        if (textColorId > 0) {

            paint.setColor(ContextCompat.getColor(context,textColorId));

        }

        invalidate();

    }

    /**

     * 设置中心文字颜色

     *

     * @param color 颜色值int

     */

    public void setTextColor(int color) {

        paint.setColor(color);

        invalidate();

    }

    /**

     * 设置中心文字大小

     *

     * @param textSize 文字大小

     */

    public void setTextSize(int textSize) {

        this.textSize = textSize;

        invalidate();

    }

    public void setPickerChangeListener(PickerChangeListener pickerChangeListener) {

        this.pickerChangeListener = pickerChangeListener;

    }

    /**

     * 获取当前选中item在数组里的下标

     *

     * @return 当前选中下标

     */

    public int getCurPosition() {

        return curPosition;

    }

}

可拖拽滚动条 - 产品瞎提需求怎么办 2023-07-25
地图源 2026-01-08

评论区