✍️作者简介:小北编程(专注于HarmonyOS、Android、Java、Web、TCP/IP等技术方向)
🐳博客主页:开源中国、稀土掘金、51cto博客、博客园、知乎、简书、慕课网、CSDN
🔔如果文章对您有一定的帮助请👉关注✨、点赞👍、收藏📂、评论💬。
🔥如需转载请参考【转载须知】
老样子先看实例:
引言
Android应用中实现滑动列表索引波浪侧边栏,同时支持根据拼音分类的快速查找,并且具备拨打电话的功能。
波浪侧边栏
1. 什么是波浪侧边栏?
滑动列表索引波浪侧边栏是一种通过滑动屏幕侧边实现快速导航的交互设计。这种设计通常以字母或拼音为基础,让用户可以快速浏览和查找列表中的内容。
2. 如何实现?
在Android应用中,可以使用RecyclerView和定制的侧边栏组件来实现滑动列表索引波浪侧边栏。通过监听滑动事件和触摸事件,实时更新列表的位置,从而实现侧边栏与列表的同步操作。
3. 优势和冲击力
-
提升用户导航效率:用户无需逐一滚动列表,通过直接点击侧边栏上的字母或拼音,即可快速跳转到相应位置,提升了用户查找内容的效率。
-
引人注目的波浪动画:为了增强用户体验,可以添加波浪状的动画效果,使侧边栏更加生动有趣,吸引用户的注意力。
代码实现
需要的引用包build.gradle.kts
implementation("androidx.recyclerview:recyclerview:1.2.1")
implementation("com.github.CymChad:BaseRecyclerViewAdapterHelper:2.9.47")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.orhanobut:logger:2.2.0")
implementation("com.belerweb:pinyin4j:2.5.1")
注意:这里
BaseRecyclerViewAdapterHelper
要在settings.gradle.kts
引用maven { url = uri("https://jitpack.io") }
波浪侧边栏代码
public class WaveSideBar extends View {
private static final String TAG = "WaveSideBar";
// 计算波浪贝塞尔曲线的角弧长值
private static final double ANGLE = Math.PI * 45 / 180;
private static final double ANGLE_R = Math.PI * 90 / 180;
private OnTouchLetterChangeListener mListener;
// 渲染字母表
private List<String> mLetters;
// 当前选中的位置
private int mChoosePosition = -1;
private int mOldPosition;
private int mNewPosition;
// 字母列表画笔
private Paint mLettersPaint = new Paint();
// 提示字母画笔
private Paint mTextPaint = new Paint();
// 波浪画笔
private Paint mWavePaint = new Paint();
private int mTextSize;
private int mHintTextSize;
private int mTextColor;
private int mWaveColor;
private int mTextColorChoose;
private int mWidth;
private int mHeight;
private int mItemHeight;
private int mPadding;
// 波浪路径
private Path mWavePath = new Path();
// 圆形路径
private Path mCirclePath = new Path();
// 手指滑动的Y点作为中心点
private int mCenterY; //中心点Y
// 贝塞尔曲线的分布半径
private int mRadius;
// 圆形半径
private int mCircleRadius;
// 用于过渡效果计算
private ValueAnimator mRatioAnimator;
// 用于绘制贝塞尔曲线的比率
private float mRatio;
// 选中字体的坐标
private float mPointX, mPointY;
// 圆形中心点X
private float mCircleCenterX;
public WaveSideBar(Context context) {
this(context, null);
}
public WaveSideBar(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WaveSideBar(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(context, attrs);
}
private void init(Context context, AttributeSet attrs) {
mLetters = Arrays.asList(context.getResources().getStringArray(R.array.waveSideBarLetters));
mTextColor = Color.parseColor("#969696");
mWaveColor = Color.parseColor("#bef9b81b");
mTextColorChoose = context.getResources().getColor(android.R.color.white);
mTextSize = context.getResources().getDimensionPixelSize(R.dimen.textSize);
mHintTextSize = context.getResources().getDimensionPixelSize(R.dimen.hintTextSize);
mPadding = context.getResources().getDimensionPixelSize(R.dimen.padding);
if (attrs != null) {
TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.waveSideBar);
mTextColor = a.getColor(R.styleable.waveSideBar_textColor, mTextColor);
mTextColorChoose = a.getColor(R.styleable.waveSideBar_chooseTextColor, mTextColorChoose);
mTextSize = a.getDimensionPixelSize(R.styleable.waveSideBar_textSize, mTextSize);
mHintTextSize = a.getDimensionPixelSize(R.styleable.waveSideBar_hintTextSize, mHintTextSize);
mWaveColor = a.getColor(R.styleable.waveSideBar_backgroundColor, mWaveColor);
mRadius = a.getDimensionPixelSize(R.styleable.waveSideBar_radius, context.getResources().getDimensionPixelSize(R.dimen.radius));
mCircleRadius = a.getDimensionPixelSize(R.styleable.waveSideBar_circledRadius, context.getResources().getDimensionPixelSize(R.dimen.circleRadius));
a.recycle();
}
mWavePaint = new Paint();
mWavePaint.setAntiAlias(true);
mWavePaint.setStyle(Paint.Style.FILL);
mWavePaint.setColor(mWaveColor);
mTextPaint.setAntiAlias(true);
mTextPaint.setColor(mTextColorChoose);
mTextPaint.setStyle(Paint.Style.FILL);
mTextPaint.setTextSize(mHintTextSize);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
final float y = event.getY();
final float x = event.getX();
mOldPosition = mChoosePosition;
mNewPosition = (int) (y / mHeight * mLetters.size());
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//限定触摸范围
if (x < mWidth - 1.5 * mRadius) {
return false;
}
mCenterY = (int) y;
startAnimator(1.0f);
break;
case MotionEvent.ACTION_MOVE:
mCenterY = (int) y;
if (mOldPosition != mNewPosition) {
if (mNewPosition >= 0 && mNewPosition < mLetters.size()) {
mChoosePosition = mNewPosition;
if (mListener != null) {
mListener.onLetterChange(mLetters.get(mNewPosition));
}
}
}
invalidate();
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
startAnimator(0f);
mChoosePosition = -1;
break;
default:
break;
}
return true;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
mHeight = MeasureSpec.getSize(heightMeasureSpec);
mWidth = getMeasuredWidth();
mItemHeight = (mHeight - mPadding) / mLetters.size();
mPointX = mWidth - 1.6f * mTextSize;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//绘制字母列表
drawLetters(canvas);
//绘制波浪
drawWavePath(canvas);
//绘制圆
drawCirclePath(canvas);
//绘制选中的字体
drawChooseText(canvas);
}
/**
* 绘制字母列表
*
* @param canvas
*/
private void drawLetters(Canvas canvas) {
RectF rectF = new RectF();
rectF.left = mPointX - mTextSize;
rectF.right = mPointX + mTextSize;
rectF.top = mTextSize / 2;
rectF.bottom = mHeight - mTextSize / 2;
mLettersPaint.reset();
mLettersPaint.setStyle(Paint.Style.FILL);
mLettersPaint.setColor(Color.parseColor("#F9F9F9"));
mLettersPaint.setAntiAlias(true);
canvas.drawRoundRect(rectF, mTextSize, mTextSize, mLettersPaint);
mLettersPaint.reset();
mLettersPaint.setStyle(Paint.Style.STROKE);
mLettersPaint.setColor(mTextColor);
mLettersPaint.setAntiAlias(true);
canvas.drawRoundRect(rectF, mTextSize, mTextSize, mLettersPaint);
for (int i = 0; i < mLetters.size(); i++) {
mLettersPaint.reset();
mLettersPaint.setColor(mTextColor);
mLettersPaint.setAntiAlias(true);
mLettersPaint.setTextSize(mTextSize);
mLettersPaint.setTextAlign(Paint.Align.CENTER);
Paint.FontMetrics fontMetrics = mLettersPaint.getFontMetrics();
float baseline = Math.abs(-fontMetrics.bottom - fontMetrics.top);
float pointY = mItemHeight * i + baseline / 2 + mPadding;
if (i == mChoosePosition) {
mPointY = pointY;
} else {
canvas.drawText(mLetters.get(i), mPointX, pointY, mLettersPaint);
}
}
}
/**
* 绘制选中的字母
*
* @param canvas
*/
private void drawChooseText(Canvas canvas) {
if (mChoosePosition != -1) {
// 绘制右侧选中字符
mLettersPaint.reset();
mLettersPaint.setColor(mTextColorChoose);
mLettersPaint.setTextSize(mTextSize);
mLettersPaint.setTextAlign(Paint.Align.CENTER);
canvas.drawText(mLetters.get(mChoosePosition), mPointX, mPointY, mLettersPaint);
// 绘制提示字符
if (mRatio >= 0.9f) {
String target = mLetters.get(mChoosePosition);
Paint.FontMetrics fontMetrics = mTextPaint.getFontMetrics();
float baseline = Math.abs(-fontMetrics.bottom - fontMetrics.top);
float x = mCircleCenterX;
float y = mCenterY + baseline / 2;
canvas.drawText(target, x, y, mTextPaint);
}
}
}
/**
* 绘制波浪
*
* @param canvas
*/
private void drawWavePath(Canvas canvas) {
mWavePath.reset();
// 移动到起始点
mWavePath.moveTo(mWidth, mCenterY - 3 * mRadius);
//计算上部控制点的Y轴位置
int controlTopY = mCenterY - 2 * mRadius;
//计算上部结束点的坐标
int endTopX = (int) (mWidth - mRadius * Math.cos(ANGLE) * mRatio);
int endTopY = (int) (controlTopY + mRadius * Math.sin(ANGLE));
mWavePath.quadTo(mWidth, controlTopY, endTopX, endTopY);
//计算中心控制点的坐标
int controlCenterX = (int) (mWidth - 1.8f * mRadius * Math.sin(ANGLE_R) * mRatio);
int controlCenterY = mCenterY;
//计算下部结束点的坐标
int controlBottomY = mCenterY + 2 * mRadius;
int endBottomX = endTopX;
int endBottomY = (int) (controlBottomY - mRadius * Math.cos(ANGLE));
mWavePath.quadTo(controlCenterX, controlCenterY, endBottomX, endBottomY);
mWavePath.quadTo(mWidth, controlBottomY, mWidth, controlBottomY + mRadius);
mWavePath.close();
canvas.drawPath(mWavePath, mWavePaint);
}
/**
* 绘制左边提示的圆
*
* @param canvas
*/
private void drawCirclePath(Canvas canvas) {
//x轴的移动路径
mCircleCenterX = (mWidth + mCircleRadius) - (2.0f * mRadius + 2.0f * mCircleRadius) * mRatio;
mCirclePath.reset();
mCirclePath.addCircle(mCircleCenterX, mCenterY, mCircleRadius, Path.Direction.CW);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
mCirclePath.op(mWavePath, Path.Op.DIFFERENCE);
}
mCirclePath.close();
canvas.drawPath(mCirclePath, mWavePaint);
}
private void startAnimator(float value) {
if (mRatioAnimator == null) {
mRatioAnimator = new ValueAnimator();
}
mRatioAnimator.cancel();
mRatioAnimator.setFloatValues(value);
mRatioAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator value) {
mRatio = (float) value.getAnimatedValue();
//球弹到位的时候,并且点击的位置变了,即点击的时候显示当前选择位置
if (mRatio == 1f && mOldPosition != mNewPosition) {
if (mNewPosition >= 0 && mNewPosition < mLetters.size()) {
mChoosePosition = mNewPosition;
if (mListener != null) {
mListener.onLetterChange(mLetters.get(mNewPosition));
}
}
}
invalidate();
}
});
mRatioAnimator.start();
}
public void setOnTouchLetterChangeListener(OnTouchLetterChangeListener listener) {
this.mListener = listener;
}
public List<String> getLetters() {
return mLetters;
}
public void setLetters(List<String> letters) {
this.mLetters = letters;
invalidate();
}
public interface OnTouchLetterChangeListener {
void onLetterChange(String letter);
}
}
适配器代码
public class UserAdapter extends BaseQuickAdapter<UserBean, BaseViewHolder> {
private List<UserBean> userList;
public UserAdapter(@Nullable List<UserBean> data) {
super(R.layout.item_user, data);
this.userList = data;
}
@Override
protected void convert(@NonNull BaseViewHolder helper, UserBean item) {
helper.setText(R.id.tv_contacts_name, item.getRealName());
helper.setText(R.id.tv_department, item.getOrganizationName());
helper.setText(R.id.tv_phone, item.getUserPhone());
helper.addOnClickListener(R.id.llUserName);
helper.addOnClickListener(R.id.ivPhone);
}
/**
* 根据分类的首字母的Char ascii值获取其第一次出现该首字母的位置
*/
public int getPositionForSection(int section) {
for (int i = 0; i < getItemCount(); i++) {
String sortStr = userList.get(i).getLetters();
if (sortStr != null && !sortStr.isEmpty()) {
char firstChar = sortStr.toUpperCase().charAt(0);
if (firstChar == section) {
return i;
}
}
}
return -1;
}
}
实例
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private UserAdapter userAdapter;
/**
* 根据拼音来排列RecyclerView里面的数据类
*/
private PinyinComparator mComparator;
private LinearLayoutManager manager;
private TitleItemDecoration mDecoration;
private List<UserBean> contactsList = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
String rqsSrc = "UserList.json";
String rqsJson = AssetsUtil.getAssetsJson(this, rqsSrc);
BaseBean baseBean = MyJson.fromJson(rqsJson, BaseBean.class);
List<UserBean> userList = MyJson.fromJson(MyJson.toJson(baseBean.getData()),
new TypeToken<ArrayList<UserBean>>() {
}.getType());
mComparator = new PinyinComparator();
Collections.sort(userList, mComparator);
manager = new LinearLayoutManager(this);
manager.setOrientation(LinearLayoutManager.VERTICAL);
binding.recyclerView.setLayoutManager(manager);
userAdapter = new UserAdapter(new ArrayList<>());
binding.recyclerView.setAdapter(userAdapter);
contactsList = filledData(userList);
refreshAdapter(contactsList);
userAdapter.setOnItemChildClickListener((adapter, view, position) -> {
UserBean item = (UserBean) adapter.getItem(position);
int id = view.getId();
if (id == R.id.llUserName) {
Toast.makeText(this, "点击条目测试:Name " + item.getRealName(), Toast.LENGTH_SHORT).show();
} else if (id == R.id.ivPhone) {
String realName = item.getRealName();
String phoneNumber = item.getUserPhone();
if (TextUtils.isEmpty(phoneNumber)) {
Toast.makeText(this, "联系人没有保留电话!", Toast.LENGTH_SHORT).show();
} else {
new AlertDialog.Builder(this)
.setTitle("拨号")
.setMessage(realName + "\n" + phoneNumber + "\n" + "请确认要拨打的电话?")
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
Intent intent = new Intent(Intent.ACTION_DIAL);
Uri ril = Uri.parse("tel:" + phoneNumber);
intent.setData(ril);
startActivity(intent);
}
})
.show();
}
}
});
//设置右侧SideBar触摸监听
binding.sideBar.setOnTouchLetterChangeListener(new WaveSideBar.OnTouchLetterChangeListener() {
@Override
public void onLetterChange(String letter) {
//该字母首次出现的位置
int position = userAdapter.getPositionForSection(letter.charAt(0));
if (position != -1) {
manager.scrollToPositionWithOffset(position, 0);
}
}
});
}
/**
* 为RecyclerView填充数据
*
* @param date
* @return
*/
private List<UserBean> filledData(List<UserBean> date) {
for (int i = 0; i < date.size(); i++) {
//汉字转换成拼音
String pinyin = PinyinUtils.getPingYin(date.get(i).getRealName());
String sortString = pinyin.substring(0, 1).toUpperCase();
// 正则表达式,判断首字母是否是英文字母
if (sortString.matches("[A-Z]")) {
date.get(i).setLetters(sortString.toUpperCase());
} else {
date.get(i).setLetters("#");
}
}
return date;
}
private void refreshAdapter(List<UserBean> contactsListBean) {
// 根据a-z进行排序源数据
Collections.sort(contactsListBean, mComparator);
userAdapter.getData().clear();
userAdapter.replaceData(contactsListBean);
if (mDecoration == null) {
mDecoration = new TitleItemDecoration(this, contactsList);
//如果add两个,那么按照先后顺序,依次渲染。
binding.recyclerView.addItemDecoration(mDecoration);
binding.recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
}
}
}
以上是大部分主要代码,由于代码比较多,只能贴出主要代码,如果有不明白的的也可以下载demo来参考,如果有积分的可以直接点击下载!
下载地址: CSDN 点击下载
如果没有积分,也想要代码的可以留言单独发,请有积分的勿骚扰!
文章写作不易请大家点赞👍、收藏📁!
总结
通过引入滑动列表索引波浪侧边栏和电话拨打功能,我们不仅提高了应用的用户体验,还为用户提供了更便捷的操作方式。这一创新设计不仅让应用更加吸引人,同时也符合现代用户对于高效、直观操作的期望。在移动应用开发中,不断尝试新的交互设计和功能整合,将是取得成功的关键之一。
无论是哪个阶段,坚持努力都是成功的关键。不要停下脚步,继续前行,即使前路崎岖,也请保持乐观和勇气。相信自己的能力,你所追求的目标定会在不久的将来实现。加油!