自定义并可深度定制的数字滚动选择器完整源代码与相关注意事项

1.核心类结构速览

Digital_Rolling_RecyclerView

初始化
├── setParent(FrameLayout) // 父容器
├── setContext(Context) // MainActivity.this
├── setAdapterCreator(AdapterCreator) // Adapter创建器
├── setTextSize(normal, selected) // 文字大小配置
├── setVisibleItemCount(count) // 可见item数量
├── setPlaceholderCount(head, tail) // 首尾占位数量
├── setTargetPosition(pos) // 初始选中位置
├── setClickToJump(boolean) // 是否开启点击跳转
├── setOnScrollListener(listener) // 滚动与点击监听(用于更新ui)
├── apply() // 执行初始化,返回RecyclerView

外部调用
├── scrollToPosition(pos, isChengXuGunDong) // 程序滚动到指定位置

工具类内部作用域的方法
└── updatePickerEffects() // 更新缩放与透明度(内部调用)

2.使用示例

java 复制代码
mDigitalRollingRecyclerView = new Digital_Rolling_RecyclerView().setParent(f)
                .setContext(mContext)
                // 语法糖,等价于如下代码,相当于传入了一个回调。
                /*
                    new AdapterCreator() {
                        @Override
                        public RecyclerView.Adapter create(int itemHeight) {
                            return new DigitalRollingRecyclerViewAdapter(itemHeight);
                        }
                    }
                 */
                .setAdapterCreator(DigitalRollingRecyclerViewAdapter::new)
                .setClickToJump(true)
                .setTextSize(TEXT_SIZE_NORMAL, TEXT_SIZE_SELECTED)
                .setVisibleItemCount(3)
                .setPlaceholderCount(1, 1)
                .setTargetPosition(8)// temperature_List索引8对应"24"
                .setOnScrollListener(new Digital_Rolling_RecyclerView.OnScrollListener() {
                    @Override
                    public void onScroll(boolean isScrolling, int position, boolean isChengXuGunDong) {
                        // todo:这里是滚动事件相关的操作被触发时,更新ui的相关操作。
                    }
                    @Override
                    public void onItemClick(int position) {
                        // todo:这里是textView被点击后,更新ui的相关操作。
                    }
                });
// 使用工具类完成Picker全部初始化:创建RV、计算高度、创建Adapter、配置吸附与滚动效果
mRecyclerView = mDigitalRollingRecyclerView.apply();

3.注意事项

3.1 自定义RecyclerView适配器(需传入itemHeight -> 控件高度)

由于需要传入itemHeight,所以工具类中才需要传入DigitalRollingRecyclerViewAdapter::new,而不是 new DigitalRollingRecyclerViewAdapter。 这俩的区别是,new出来的对象已经被创建在了栈中,而DigitalRollingRecyclerViewAdapter::new只是一个回调,真正创建适配的地方在工具类-apply()中的

复制代码
RecyclerView.Adapter adapter = adapterCreator.create(mItemHeight);

如上代码中。

当然你也可以自己魔改一下,在外面先创建好RecyclerView,然后把该view添加到FrameLayout中。此时你只需要传入itemHeight就好了。

但工具类秉承着用户只需要传入所有的数据,逻辑部分工具类自己内部实现,用户直接拿成品的原则,最好还是不要这么做。

java 复制代码
private class DigitalRollingRecyclerViewAdapter extends RecyclerView.Adapter<DigitalRollingRecyclerViewAdapter.ViewHolder>{

        private int itemHeight;

        DigitalRollingRecyclerViewAdapter(int itemHeight) {
            this.itemHeight = itemHeight;
        }

        @NonNull
        @Override
        public DigitalRollingRecyclerViewAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
            TextView textView = new TextView(getContext());
            textView.setLayoutParams(new RecyclerView.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    itemHeight
            ));
            textView.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); // 水平居左
            textView.setTextColor(getContext().getColor(R.color.TitleTextColor));
            textView.setIncludeFontPadding(false);
            return new ViewHolder(textView);
        }

        @Override
        public void onBindViewHolder(@NonNull DigitalRollingRecyclerViewAdapter.ViewHolder holder, int position) {
            // 首尾为占位item,不显示内容
            if (position == 0 || position == temperature_List.length + 1) {
                holder.textView.setText("");
                holder.textView.setAlpha(0f);
            } else {
                holder.textView.setText(temperature_List[position - 1]);
                holder.textView.setAlpha(1f);
                // 默认小文字,由updatePickerEffects根据滚动位置实时调整
                holder.textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE_NORMAL);
            }
            // 调试背景色,确认item占据区域,开发完成后可删除
//            holder.textView.setBackground(AppApplication.getContext().getDrawable(R.color.BarColor1));
        }

        @Override
        public int getItemCount() {
            // 真实数据 + 首尾各一个占位,确保Hi和Lo能滚到视觉中心
            return temperature_List.length + 2;
        }

        class ViewHolder extends RecyclerView.ViewHolder{
            TextView textView;
            ViewHolder(TextView textView){
                super(textView);
                this.textView = textView;
            }
        }
    }

4.工具类完整代码

java 复制代码
package com.sth.Uitility;

import android.content.Context;
import android.util.Log;
import android.util.TypedValue;
import android.view.MotionEvent; // 新增:点击监听需要
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.LinearSnapHelper;
import androidx.recyclerview.widget.RecyclerView;

/**
 * @File Name: Digital_Rolling_RecyclerView.java
 * @Date: March 5, 2026
 * @Version: 1.0
 * @Author: 施棠海
 * @描述: 数字滚动选择器效果配置器。
 * @: 负责:在父容器中自动创建RecyclerView、计算item高度、配置吸附回弹与滚动视觉效果
 * @: 业务方需提供AdapterCreator以注入业务数据与ViewHolder
 */
public class Digital_Rolling_RecyclerView {
    private static final String tag = "Digital_Rolling_RecyclerView";

    private FrameLayout parent;             // 父容器
    private Context context;                // MainActivity.this  Context
    private AdapterCreator adapterCreator;  // Adapter创建器,接收itemHeight返回Adapter
    private float textSizeNormal = 60f;     // 未选中状态文字大小(SP)
    private float textSizeSelected = 90f;   // 选中状态文字大小(SP)
    private int headPlaceholder = 1;        // 头部占位item数量
    private int tailPlaceholder = 1;        // 尾部占位item数量
    private int targetPosition = 1;         // 初始选中位置(Adapter绝对position)
    private int visibleItemCount = 3;       // 可见item数量
    private int realIndex;
    private boolean clickToJump = true;     // 点击跳转
    private boolean isChengXuGunDong = false; // 该滚动是否是程序滚动
    private RecyclerView mRecyclerView;     // 内部创建的RecyclerView

    private OnScrollListener listener;

    // 新增:item高度与SnapHelper实例,供点击跳转时计算精确滚动距离
    private int mItemHeight;
    private LinearSnapHelper mSnapHelper;

    /**
     * Adapter创建器接口
     * 由业务方实现,根据计算后的item高度创建对应Adapter
     */
    public interface AdapterCreator {
        RecyclerView.Adapter create(int itemHeight);
    }

    public interface OnScrollListener {
        /**
         * 滚动状态监听
         * @param isScrolling 滚动中 true/否
         */
        void onScroll(boolean isScrolling,int position, boolean isChengXuGunDong);
        /**
         * item点击监听
         * @param position 点击位置
         */
        void onItemClick(int position);
    }

    public Digital_Rolling_RecyclerView setOnScrollListener(OnScrollListener listener) {
        this.listener = listener;
        return this;
    }

    /**
     * 设置父容器
     * @param parent FrameLayout
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setParent(FrameLayout parent) {
        this.parent = parent;
        return this;
    }

    /**
     * 设置Context
     * @param context Context MainActivity.this
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setContext(Context context) {
        this.context = context;
        return this;
    }

    /**
     * 设置Adapter创建器
     * @param creator AdapterCreator
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setAdapterCreator(AdapterCreator creator) {
        this.adapterCreator = creator;
        return this;
    }

    /**
     * 设置是否开启点击跳转
     * @param clickToJump  boolean
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setClickToJump(boolean clickToJump) {
        this.clickToJump = clickToJump;
        return this;
    }

    /**
     * 设置文字大小
     * @param textSizeNormal   float 正常状态文字大小
     * @param textSizeSelected float 选中状态文字大小
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setTextSize(float textSizeNormal, float textSizeSelected) {
        this.textSizeNormal = textSizeNormal;
        this.textSizeSelected = textSizeSelected;
        return this;
    }

    /**
     * 设置可见item数量
     * @param visibleItemCount int 可见item数量
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setVisibleItemCount(int visibleItemCount) {
        this.visibleItemCount = visibleItemCount;
        return this;
    }

    /**
     *  设置头部、尾部占位item数量
     * @param head int 头部占位item数量
     * @param tail int 尾部占位item数量
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setPlaceholderCount(int head, int tail) {
        this.headPlaceholder = head;
        this.tailPlaceholder = tail;
        return this;
    }

    /**
     * 设置初始选中位置
     * @param targetPosition
     * @return Digital_Rolling_RecyclerView
     */
    public Digital_Rolling_RecyclerView setTargetPosition(int targetPosition) {
        this.targetPosition = targetPosition;
        return this;
    }

    /**
     * 执行初始化:创建RV、addView、计算高度、创建Adapter、配置效果
     * @return 创建并配置完成的RecyclerView
     */
    public RecyclerView apply() {
        if (parent == null || context == null || adapterCreator == null) {
            Log.e(tag, "parent、context、adapterCreator必须提前设置");
            return null;
        }

        // 创建RecyclerView,高度MATCH_PARENT填满父容器,单列竖向,item间零间距
        mRecyclerView = new GridRecyclerFactory(
                context,
                -1, -1,
                0, 0, 0, 0, 0)
                .setNumRowsAndDColumns(1, 3)
                .recyclerViewinit(true);
        parent.addView(mRecyclerView);

        // 延迟到布局完成后获取容器实际高度,动态计算item高度(均分3份),再创建Adapter并配置滚动效果
        // 必须在post中执行,因为addView后尚未measure,此时getHeight()为0
        mRecyclerView.post(() -> {
            int containerHeight = mRecyclerView.getHeight();
            if (containerHeight <= 0) {
                Log.e(tag, "容器高度异常: " + containerHeight);
                return;
            }

            // visibleItemCount个item均分容器高度,无间距,确保中间item中位线与容器中位线重合
            mItemHeight = containerHeight / visibleItemCount;
            Log.i(tag, "容器高度=" + containerHeight + " item高度=" + mItemHeight);

            RecyclerView.Adapter adapter = adapterCreator.create(mItemHeight);

            // 吸附到中心:滚动停止后自动将最近item的中位线回弹到容器中心
            // 使用LinearSnapHelper而非PagerSnapHelper,前者针对单item中心吸附更精准,后者按整页吸附容易错位
            mSnapHelper = new LinearSnapHelper();
            mSnapHelper.attachToRecyclerView(mRecyclerView);

            // 边界回弹效果(overscroll时发光/拉伸)
            mRecyclerView.setOverScrollMode(View.OVER_SCROLL_ALWAYS);

            // 绑定Adapter
            mRecyclerView.setAdapter(adapter);

            // 滚动时动态调整文字大小与透明度
            mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
                @Override
                public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
                    updatePickerEffects(recyclerView, adapter.getItemCount());

                    // 获取当前滚动状态
                    int state = mRecyclerView.getScrollState();
                    // 0=SCROLL_STATE_IDLE(停止)  1=DRAGGING(手指拖动)  2=SETTLING(惯性/吸附中)
                    boolean isScrolling = state != RecyclerView.SCROLL_STATE_IDLE;
                    // 滚动完全停止
                    View centerView = mSnapHelper.findSnapView(recyclerView.getLayoutManager());
                    int position = recyclerView.getChildAdapterPosition(centerView);
                    // 减去头部占位得到真实数据索引
                    realIndex = position - headPlaceholder;
                    listener.onScroll(isScrolling,realIndex,isChengXuGunDong);
                }
            });

            // 滚动到初始位置
            mRecyclerView.scrollToPosition(targetPosition);
            // scrollToPosition不会触发onScrolled,需手动执行一次视觉效果更新
            mRecyclerView.post(() -> updatePickerEffects(mRecyclerView, adapter.getItemCount()));

            if (clickToJump) {
                // 点击item自动吸附跳转:点击哪个真实数据item,就滚动到视觉中心
                mRecyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
                    private float downX, downY;
                    private static final float CLICK_THRESHOLD = 10f; // 像素阈值,超过视为滑动

                    @Override
                    public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
                        switch (e.getAction()) {
                            case MotionEvent.ACTION_DOWN:
                                downX = e.getX();
                                downY = e.getY();
                                break;
                            case MotionEvent.ACTION_UP:
                                float dx = Math.abs(e.getX() - downX);
                                float dy = Math.abs(e.getY() - downY);
                                // 位移小于阈值判定为点击
                                if (dx < CLICK_THRESHOLD && dy < CLICK_THRESHOLD) {
                                    View child = rv.findChildViewUnder(e.getX(), e.getY());
                                    if (child != null) {
                                        int targetPos = rv.getChildAdapterPosition(child);
                                        if (targetPos == RecyclerView.NO_POSITION) break;

                                        int total = rv.getAdapter() != null ? rv.getAdapter().getItemCount() : 0;
                                        // 忽略头部/尾部占位item的点击,不执行跳转
                                        if (targetPos < headPlaceholder || targetPos >= total - tailPlaceholder) {
                                            Log.i(tag, "点击占位item,忽略");
                                            break;
                                        }
                                        scrollToPosition(targetPos,false);
                                        listener.onItemClick(realIndex);
                                    }
                                }
                                break;
                        }
                        return false; // 不拦截,手指拖动/惯性滚动照常
                    }

                    @Override
                    public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
                    }

                    @Override
                    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
                    }
                });
            }

            // 滚动结束后获取中心item的position(结合你已有的LinearSnapHelper)
            mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
                @Override
                public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
                    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                        // 获取当前滚动状态
                        int state = mRecyclerView.getScrollState();
                        // 0=SCROLL_STATE_IDLE(停止)  1=DRAGGING(手指拖动)  2=SETTLING(惯性/吸附中)
                        boolean isScrolling = state != RecyclerView.SCROLL_STATE_IDLE;
                        // 滚动完全停止
                        View centerView = mSnapHelper.findSnapView(rv.getLayoutManager());
                        int position = rv.getChildAdapterPosition(centerView);
                        // 减去头部占位得到真实数据索引
                        int realIndex = position - headPlaceholder;
                        listener.onScroll(isScrolling,realIndex,isChengXuGunDong);
                        isChengXuGunDong = false;
                    }
                }
            });
        });

        return mRecyclerView;
    }

    /**
     * 滚动到指定位置
     * @param position 待滚动位置
     * @param isChengXuGunDong 是否是程序滚动
     */
    public void scrollToPosition(int position,boolean isChengXuGunDong){
        this.isChengXuGunDong = isChengXuGunDong;
        // 使用自定义LinearSmoothScroller实现精确居中滚动
        // 重写calculateDyToMakeVisible,返回将目标View中心与RV中心对齐的像素距离
        RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager();
        if (lm instanceof LinearLayoutManager) {
            LinearSmoothScroller scroller = new LinearSmoothScroller(mRecyclerView.getContext()) {
                @Override
                public int calculateDyToMakeVisible(View view, int snapPreference) {
                    RecyclerView.LayoutManager layoutManager = getLayoutManager();
                    if (layoutManager == null || !layoutManager.canScrollVertically()) {
                        return 0;
                    }
                    // 获取目标View的上下边界(含装饰边距)
                    int top = layoutManager.getDecoratedTop(view);
                    int bottom = layoutManager.getDecoratedBottom(view);
                    // 获取RecyclerView内容区域的上下边界
                    int parentTop = layoutManager.getPaddingTop();
                    int parentBottom = layoutManager.getHeight() - layoutManager.getPaddingBottom();
                    // 计算中心线
                    int parentCenter = (parentTop + parentBottom) / 2;
                    int childCenter = top + (bottom - top) / 2;
                    // 返回值:正数向下滚,负数向上滚,直到childCenter与parentCenter重合
                    return parentCenter - childCenter;
                }
            };
            scroller.setTargetPosition(position);
            ((LinearLayoutManager) lm).startSmoothScroll(scroller);
            Log.i(tag, "SmoothScroller居中滚动到position=" + position);
        }
    }

    /**
     * 更新Picker视觉效果:中间item变大+不透明,上下item变小+变淡
     * 根据每个item中心与RecyclerView中心的距离计算scale与alpha
     */
    private void updatePickerEffects(RecyclerView recyclerView, int totalItemCount) {
        int centerY = recyclerView.getHeight() / 2;                 // RecyclerView中心线Y坐标
        for (int i = 0; i < recyclerView.getChildCount(); i++) {    // 遍历当前可见的所有item
            View child = recyclerView.getChildAt(i);                // 获取当前item视图
            if (child == null) continue;                            // 防御性校验,避免空指针

            int pos = recyclerView.getChildAdapterPosition(child);  // 获取该item在Adapter中的绝对位置
            // 首尾占位item完全隐藏,不参与视觉效果计算
            if (pos < headPlaceholder || pos >= totalItemCount - tailPlaceholder) {
                child.setAlpha(0f);                                 // 占位item不可见
                continue;                                           // 跳过后续计算
            }

            int childCenterY = child.getTop() + (child.getHeight() / 2);  // 计算item中心点Y坐标
            float distance = Math.abs(centerY - childCenterY);      // 计算与中心线的绝对距离
            float maxDist = child.getHeight();                      // 最大参考距离为一个item高度

            // 距离比例:0(正中)~ 1(偏离一个item高度)
            float ratio = Math.min(1f, distance / maxDist);         // 限制ratio上限为1,避免过度衰减

            // alpha: 正中1.0,偏离后线性降低到0.2
            float alpha = 1.0f - (0.8f * ratio);                    // 1.0 - 0.8 = 0.2,即最淡时保留20%透明度
            child.setAlpha(alpha);                                  // 应用透明度

            // 文字大小: 正中textSizeSelected,偏离后线性缩小到textSizeNormal
            TextView tv = (TextView) child;                         // 强转为TextView以修改文字大小
            float textSize = textSizeNormal + (textSizeSelected - textSizeNormal) * (1f - ratio);  // 线性插值计算当前文字大小
            tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize);   // 应用文字大小(单位SP)
        }
    }
}
相关推荐
z落落1 小时前
C# 索引器 this[]
开发语言·c#
csdn_aspnet1 小时前
C# List 移除某个属性值中最大的值
开发语言·c#·list
2601_961194021 小时前
2026六级词汇资料电子版|大学英语六级核心词汇PDF
java·spring·eclipse·pdf·tomcat·hibernate
xindon121 小时前
go语言项目部署的makefile
开发语言·后端·golang
布朗克1681 小时前
18 面向对象综合实战——设计一个图书管理系统
java·面试·职场和发展·面向对象实战
老毛肚1 小时前
记一次逆向
开发语言·python
凯瑟琳.奥古斯特1 小时前
力扣1002题C++解法详解
开发语言·c++·算法·leetcode·职场和发展
钟灵9211 小时前
C++【模板初阶】
开发语言·c++·笔记·c#