效果图展示
MainActivity.xml
public class MainActivity extends AppCompatActivity implements SideBarView.OnTouchLetterChangedListener {
private FriendAdapter adapter;
private ListView listView;
private SideBarView sideView;
private List<FriendInfo> friends;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initData();//数据准备
initView();
setListener();//侧边索引暴露出的监听
}
private void initView() {
listView = ((ListView) findViewById(R.id.lv_friends_listView));
sideView = ((SideBarView) findViewById(R.id.sbv_friends_bar));
}
private void setListener() {
sideView.setOnTouchLetterChangedListener(this);
}
private void initData() {
friends = new ArrayList<>();
adapter = new FriendAdapter(this, friends);
listView.setAdapter(adapter);
//联系人数据数组放在了AppProperties
String[] names = AppProperties.names;
for (String name:names) {
FriendInfo friend = new FriendInfo();
friend.setName(name);
friend.setPinyin(PinyinUtils.getPinyin(name));
String convert = friend.getPinyin().substring(0, 1).toUpperCase();
//利用拼音获取首字母大写,特殊字符填入#
if (convert.matches("[A-Z]")) {
friend.setFirstLetter(convert);
}else{
friend.setFirstLetter("#");
}
friends.add(friend);
}
//通过首字母排序list,如果是以#开头就默认在最后
Collections.sort(friends, new Comparator<FriendInfo>() {
@Override
public int compare(FriendInfo f1, FriendInfo f2) {
if (f1.getFirstLetter().contains("#")) {
return 1;
} else if (f2.getFirstLetter().contains("#")) {
return -1;
}
else{
return f1.getFirstLetter().compareTo(f2.getFirstLetter());
}
}
});
adapter.addAll(friends);
adapter.notifyDataSetChanged();
}
@Override
public void onTouchLetterChanged(String letter) {
if (!friends.isEmpty()) {
for (int i = 0; i < friends.size(); i++) {
FriendInfo friend = friends.get(i);
String s = String.valueOf(friend.getPinyin().charAt(0));
if (s.equals(letter)) { // 匹配成功, 中断循环, 跳转到i位置
listView.setSelection(i);
break;
}
}
}
}
}
关于Collections.sort
SideBarView
使用了贝塞尔曲线动态设置,看不懂
public class SideBarView extends View {
private OnTouchLetterChangedListener mListener;
public interface OnTouchLetterChangedListener {
void onTouchLetterChanged(String letter);
}
public void setOnTouchLetterChangedListener(OnTouchLetterChangedListener listener) {
this.mListener = listener;
}
// 向右偏移多少画字符, default 30
float offset = 30.0f;
// 最小字体大小
int minSize = 24;
// 最大字体大小
int MaxSize = 48;
// 提示字体大小
int flagSize = 52;
// 提示字符的额外偏移
float flagOffSet = 20.0f;
// 贝塞尔曲线控制的高度
float mMaxBezierHeight = 150.0f;
// 贝塞尔曲线单侧宽度
float mMaxBezierWidth = 240.0f;
// 贝塞尔曲线单侧模拟线量
int mMaxBezierLines = 32;
// 列表字符颜色
int fontColor = 0xffffffff;
// 提示字符颜色
int flagFontColor = 0xffd33e48;
private final String[] ConstChar = AppProperties.SIDEBAR_CHAR_LIST;
int choose = -1;
Paint paint = new Paint();
PointF t_paint = new PointF();
PointF[] pf1;
PointF[] pf2;
float offSet[] = new float[ConstChar.length]; // 记录每一个字母的x方向偏移量, 数字<=0
PointF pointF = new PointF();
Scroller scroller;
boolean animation = false;
float animationOffset;
boolean hideAnimation = false;
int mAlpha = 255;
Handler mHideWaitingHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
if (msg.what == 1) {
hideAnimation = true;
animation = false;
SideBarView.this.invalidate();
return;
}
super.handleMessage(msg);
}
};
public SideBarView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initData(context, attrs);
}
public SideBarView(Context context, AttributeSet attrs) {
super(context, attrs);
initData(context, attrs);
}
public SideBarView(Context context) {
super(context);
initData(null, null);
}
private void initData(Context context, AttributeSet attrs) {
if (context != null && attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FancyIndexer, 0, 0);
offset = a.getDimension(R.styleable.FancyIndexer_widthOffset, offset);
minSize = a.getInteger(R.styleable.FancyIndexer_minFontSize, minSize);
MaxSize = a.getInteger(R.styleable.FancyIndexer_maxFontSize, MaxSize);
flagSize = a.getInteger(R.styleable.FancyIndexer_tipFontSize, flagSize);
mMaxBezierHeight = a.getDimension(R.styleable.FancyIndexer_maxBezierHeight, mMaxBezierHeight);
mMaxBezierWidth = a.getDimension(R.styleable.FancyIndexer_maxBezierWidth, mMaxBezierWidth);
mMaxBezierLines = a.getInteger(R.styleable.FancyIndexer_maxBezierLines, mMaxBezierLines);
flagOffSet = a.getDimension(R.styleable.FancyIndexer_additionalTipOffset, flagOffSet);
// 颜色
fontColor = a.getColor(R.styleable.FancyIndexer_fontColor, fontColor);
// 提示颜色
flagFontColor = a.getColor(R.styleable.FancyIndexer_tipFontColor, flagFontColor);
a.recycle();
}
scroller = new Scroller(getContext());
t_paint.x = 0;
t_paint.y = -10 * mMaxBezierWidth;
pf1 = new PointF[mMaxBezierLines];
pf2 = new PointF[mMaxBezierLines];
calculateBezierPoints();
}
@Override
protected void onDraw(Canvas canvas) {
// 控件宽高
int height = getHeight();
int width = getWidth();
// 单个字母高度
float singleHeight = height / (float) ConstChar.length;
int workHeight = 0;
if (mAlpha == 0)
return;
paint.reset();
int saveCount = 0;
if (hideAnimation) {
saveCount = canvas.save();
canvas.saveLayerAlpha(0, 0, width, height, mAlpha, Canvas.ALL_SAVE_FLAG);
}
for (int i = 0; i < ConstChar.length; i++) {
paint.setColor(fontColor);
paint.setAntiAlias(true);
float xPos = width - offset;
float yPos = workHeight + singleHeight / 2;
int fontSize = adjustFontSize(i, yPos);
paint.setTextSize(fontSize);
// 添加一个字母的高度
workHeight += singleHeight;
// 绘制字母
drawTextInCenter(canvas, ConstChar[i], xPos + ajustXPosAnimation(i, yPos), yPos);
// 绘制的字母和当前触摸到的一致, 绘制被选中字母
if (i == choose) {
paint.setColor(flagFontColor);
paint.setFakeBoldText(true);
paint.setTextSize(flagSize);
yPos = t_paint.y;
float pos = 0;
if (animation || hideAnimation) {
pos = pointF.x;
yPos = pointF.y;
} else {
pos = xPos + ajustXPosAnimation(i, yPos) - flagOffSet;
pointF.x = pos;
pointF.y = yPos;
}
drawTextInCenter(canvas, ConstChar[i], pos, yPos);
}
paint.reset();
}
if (hideAnimation) {
canvas.restoreToCount(saveCount);
}
}
/**
* @param canvas 画板
* @param string 被绘制的字母
* @param xCenter 字母的中心x方向位置
* @param yCenter 字母的中心y方向位置
*/
private void drawTextInCenter(Canvas canvas, String string, float xCenter, float yCenter) {
Paint.FontMetrics fm = paint.getFontMetrics();
float fontHeight = paint.getFontSpacing();
float drawY = yCenter + fontHeight / 2 - fm.descent;
if (drawY < -fm.ascent - fm.descent)
drawY = -fm.ascent - fm.descent;
if (drawY > getHeight())
drawY = getHeight();
paint.setTextAlign(Paint.Align.CENTER);
canvas.drawText(string, xCenter, drawY, paint);
}
private int adjustFontSize(int i, float yPos) {
// 根据水平方向偏移量计算出一个放大的字号
float adjustX = Math.abs(ajustXPosAnimation(i, yPos));
int adjustSize = (int) ((MaxSize - minSize) * adjustX / mMaxBezierHeight) + minSize;
return adjustSize;
}
/**
* x 方向的向左偏移量
*
* @param i 当前字母的索引
* @param yPos y方向的初始位置
* @return
*/
private float ajustXPosAnimation(int i, float yPos) {
float offset;
if (this.animation || this.hideAnimation) {
// 正在动画中或在做隐藏动画
offset = offSet[i];
if (offset != 0.0f) {
offset += this.animationOffset;
if (offset > 0)
offset = 0;
}
} else {
// 根据当前字母y方向位置, 计算水平方向偏移量
offset = adjustXPos(yPos);
// 当前触摸的x方向位置
float xPos = t_paint.x;
float width = getWidth() - this.offset;
width = width - 60;
// 字母绘制时向左偏移量 进行修正, offset需要是<=0的值
if (offset != 0.0f && xPos > width)
offset += (xPos - width);
if (offset > 0)
offset = 0;
offSet[i] = offset;
}
return offset;
}
private float adjustXPos(float yPos) {
float dis = yPos - t_paint.y; // 字母y方向位置和触摸时y值坐标的差值, 距离越小, 得到的水平方向偏差越大
if (dis > -mMaxBezierWidth && dis < mMaxBezierWidth) {
// 在2个贝赛尔曲线宽度范围以内 (一个贝赛尔曲线宽度是指一个山峰的一边)
// 第一段 曲线
if (dis > mMaxBezierWidth / 4) {
for (int i = mMaxBezierLines - 1; i > 0; i--) {
// 从下到上, 逐个计算
if (dis == -pf1[i].y) // 落在点上
return pf1[i].x;
// 如果距离dis落在两个贝塞尔曲线模拟点之间, 通过三角函数计算得到当前dis对应的x方向偏移量
if (dis > -pf1[i].y && dis < -pf1[i - 1].y) {
return (dis + pf1[i].y) * (pf1[i - 1].x - pf1[i].x) / (-pf1[i - 1].y + pf1[i].y) + pf1[i].x;
}
}
return pf1[0].x;
}
// 第三段 曲线, 和第一段曲线对称
if (dis < -mMaxBezierWidth / 4) {
for (int i = 0; i < mMaxBezierLines - 1; i++) {
// 从上到下
if (dis == pf1[i].y) // 落在点上
return pf1[i].x;
// 如果距离dis落在两个贝塞尔曲线模拟点之间, 通过三角函数计算得到当前dis对应的x方向偏移量
if (dis > pf1[i].y && dis < pf1[i + 1].y) {
return (dis - pf1[i].y) * (pf1[i + 1].x - pf1[i].x) / (pf1[i + 1].y - pf1[i].y) + pf1[i].x;
}
}
return pf1[mMaxBezierLines - 1].x;
}
// 第二段 峰顶曲线
for (int i = 0; i < mMaxBezierLines - 1; i++) {
if (dis == pf2[i].y)
return pf2[i].x;
// 如果距离dis落在两个贝塞尔曲线模拟点之间, 通过三角函数计算得到当前dis对应的x方向偏移量
if (dis > pf2[i].y && dis < pf2[i + 1].y) {
return (dis - pf2[i].y) * (pf2[i + 1].x - pf2[i].x) / (pf2[i + 1].y - pf2[i].y) + pf2[i].x;
}
}
return pf2[mMaxBezierLines - 1].x;
}
return 0.0f;
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
final int action = event.getAction();
final float y = event.getY();
final int oldmChooseIndex = choose;
final OnTouchLetterChangedListener listener = mListener;
final int c = (int) (y / getHeight() * ConstChar.length);
switch (action) {
case MotionEvent.ACTION_DOWN:
if (this.getWidth() > offset) {
if (event.getX() < this.getWidth() - offset)
return false;
}
mHideWaitingHandler.removeMessages(1);
scroller.abortAnimation();
animation = false;
hideAnimation = false;
mAlpha = 255;
t_paint.x = event.getX();
t_paint.y = event.getY();
if (oldmChooseIndex != c && listener != null) {
if (c > 0 && c < ConstChar.length) {
listener.onTouchLetterChanged(ConstChar[c]);
choose = c;
}
}
invalidate();
break;
case MotionEvent.ACTION_MOVE:
t_paint.x = event.getX();
t_paint.y = event.getY();
invalidate();
if (oldmChooseIndex != c && listener != null) {
if (c >= 0 && c < ConstChar.length) {
listener.onTouchLetterChanged(ConstChar[c]);
choose = c;
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
t_paint.x = event.getX();
t_paint.y = event.getY();
scroller.startScroll(0, 0, (int) mMaxBezierHeight, 0, 500);
animation = true;
postInvalidate();
break;
}
return true;
}
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
if (animation) {
float x = scroller.getCurrX();
animationOffset = x;
} else if (hideAnimation) {
mAlpha = 255 - scroller.getCurrX();
}
invalidate();
} else if (scroller.isFinished()) {
if (animation) {
mHideWaitingHandler.sendEmptyMessage(1);
} else if (hideAnimation) {
hideAnimation = false;
this.choose = -1;
t_paint.x = -10000;
t_paint.y = -10000;
}
}
}
/**
* 计算出所有贝塞尔曲线上的点
* 个数为 mMaxBezierLines * 2 = 64
*/
private void calculateBezierPoints() {
PointF mStart = new PointF(); // 开始点
PointF mEnd = new PointF(); // 结束点
PointF mControl = new PointF(); // 控制点
// 计算第一段红色部分 贝赛尔曲线的点
// 开始点
mStart.x = 0.0f;
mStart.y = -mMaxBezierWidth;
// 控制点
mControl.x = 0.0f;
mControl.y = -mMaxBezierWidth / 2;
// 结束点
mEnd.x = -mMaxBezierHeight / 2;
mEnd.y = -mMaxBezierWidth / 4;
pf1[0] = new PointF();
pf1[mMaxBezierLines - 1] = new PointF();
pf1[0].set(mStart);
pf1[mMaxBezierLines - 1].set(mEnd);
for (int i = 1; i < mMaxBezierLines - 1; i++) {
pf1[i] = new PointF();
pf1[i].x = calculateBezier(mStart.x, mEnd.x, mControl.x, i / (float) mMaxBezierLines);
pf1[i].y = calculateBezier(mStart.y, mEnd.y, mControl.y, i / (float) mMaxBezierLines);
}
// 计算第二段蓝色部分 贝赛尔曲线的点
mStart.y = -mMaxBezierWidth / 4;
mStart.x = -mMaxBezierHeight / 2;
mControl.y = 0.0f;
mControl.x = -mMaxBezierHeight;
mEnd.y = mMaxBezierWidth / 4;
mEnd.x = -mMaxBezierHeight / 2;
pf2[0] = new PointF();
pf2[mMaxBezierLines - 1] = new PointF();
pf2[0].set(mStart);
pf2[mMaxBezierLines - 1].set(mEnd);
for (int i = 1; i < mMaxBezierLines - 1; i++) {
pf2[i] = new PointF();
pf2[i].x = calculateBezier(mStart.x, mEnd.x, mControl.x, i / (float) mMaxBezierLines);
pf2[i].y = calculateBezier(mStart.y, mEnd.y, mControl.y, i / (float) mMaxBezierLines);
}
}
/**
* 贝塞尔曲线核心算法
*
* @param start
* @param end
* @param control
* @param val
* @return 公式及动图, 维基百科: https://en.wikipedia.org/wiki/B%C3%A9zier_curve
* 中文可参考此网站: http://blog.youkuaiyun.com/likendsl/article/details/7852658
*/
private float calculateBezier(float start, float end, float control, float val) {
float t = val;
float s = 1 - t;
float ret = start * s * s + 2 * control * s * t + end * t * t;
return ret;
}
左侧ListAdapter(FriendAdapter)
public class FriendAdapter extends BaseAdapter {
private List<FriendInfo> list;
private LayoutInflater inflater;// 填充布局
public FriendAdapter(Context context, List<FriendInfo> list) {
this.list = list;
this.inflater = LayoutInflater.from(context);
}
class ViewHolder {
TextView username, showLetter;
}
@Override
public int getCount() {
return list.size();
}
@Override
public Object getItem(int position) {
return null;
}
@Override
public long getItemId(int position) {
return 0;
}
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
convertView =inflater.inflate(R.layout.listview_item, null);
holder = new ViewHolder();
holder.showLetter = (TextView) convertView.findViewById(R.id.tv_friend_title);
holder.username = (TextView) convertView.findViewById(R.id.tv_friend_name);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
FriendInfo user = list.get(position);
holder.username.setText(user.getName());
//获得当前position是属于哪个分组
int sectionForPosition = getSectionForPosition(position);
//获得该分组第一项的position
int positionForSection = getPositionForSection(sectionForPosition);
//查看当前的是否是该组的第一个,是的话显示showLetter
if (position == positionForSection) {
holder.showLetter.setVisibility(View.VISIBLE);
holder.showLetter.setText(user.getFirstLetter());
} else {
holder.showLetter.setVisibility(View.GONE);
}
return convertView;
}
//传入一个分组值[A....Z],获得该分组的第一项的position
public int getPositionForSection(int sectionIndex) {
for (int i = 0; i < list.size(); i++) {
if (list.get(i).getFirstLetter().charAt(0) == sectionIndex) {
return i;
//list是排序后的,只要一遇到该分组值就直接返回
}
}
return -1;
}
//传入一个position,获得该position所在的分组
public int getSectionForPosition(int position) {
return list.get(position).getFirstLetter().charAt(0);
}
public void addAll(List<FriendInfo> data) {
this.list.addAll(data);
notifyDataSetChanged();
}
}
FriendInfo
public class FriendInfo {
private String name;
private String pinyin;
private String firstLetter;
public String getFirstLetter() {
return firstLetter;
}
public void setFirstLetter(String firstLetter) {
this.firstLetter = firstLetter;
}
public FriendInfo() {
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPinyin() {
return pinyin;
}
public void setPinyin(String pinyin) {
this.pinyin = pinyin;
}
@Override
public String toString() {
return "FriendEntity{" +
"name='" + name + '\'' +
", pinyin='" + pinyin + '\'' +
'}';
}
}
PinyinUtils
public class PinyinUtils {
public static String getPinyin(String str) {
HanyuPinyinOutputFormat format = new HanyuPinyinOutputFormat();
format.setCaseType(HanyuPinyinCaseType.UPPERCASE);
format.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
StringBuilder sb = new StringBuilder();
char[] charArray = str.toCharArray();
for (int i = 0; i < charArray.length; i++) {
char c = charArray[i];
if (Character.isWhitespace(c)) { //如果是空格
continue;
}
if (c > 128 || c < -127) { //汉字
try { //根据字符获取对应的拼音
String s = PinyinHelper.toHanyuPinyinStringArray(c, format)[0];
sb.append(s);
} catch (BadHanyuPinyinOutputFormatCombination e) {
e.printStackTrace();
}
} else {//除汉字以外的直接添加至StringBuilder
sb.append(c);
}
}
return sb.toString();
}
}
attrs
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="FancyIndexer">
<attr name="widthOffset" format="dimension"/>
<attr name="minFontSize" format="integer"/>
<attr name="maxFontSize" format="integer"/>
<attr name="tipFontSize" format="integer"/>
<attr name="maxBezierHeight" format="dimension"/>
<attr name="maxBezierWidth" format="dimension"/>
<attr name="maxBezierLines" format="integer"/>
<attr name="additionalTipOffset" format="dimension"/>
<attr name="fontColor" format="color"/>
<attr name="tipFontColor" format="color"/>
</declare-styleable>
</resources>
activity_main
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:poplar="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#d5d5d5">
<ListView
android:id="@+id/lv_friends_listView"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<com.weitext.wavesidebaractivity.activities.SideBarView
android:id="@+id/sbv_friends_bar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="5dp"
android:layout_marginTop="5dp"
poplar:additionalTipOffset="40dp"
poplar:fontColor="#6e6e6e"
poplar:maxBezierHeight="150dp"
poplar:maxBezierWidth="180dp"
poplar:maxFontSize="60"
poplar:minFontSize="32"
poplar:tipFontColor="#41c2fc"
poplar:tipFontSize="72"
poplar:widthOffset="15dp"/>
</RelativeLayout>
listview_item
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:id="@+id/tv_friend_title"
android:layout_width="match_parent"
android:layout_height="20dp"
android:background="#CFCECE"
android:gravity="center_vertical"
android:paddingLeft="20dp"
android:text="A"
android:textColor="#232323"
android:textSize="14sp"/>
<TextView
android:id="@+id/tv_friend_name"
android:layout_width="match_parent"
android:layout_height="60dp"
android:gravity="center_vertical"
android:paddingLeft="20dp"
android:text="海绵宝宝"
android:textColor="#090909"
android:textSize="16sp"/>
</LinearLayout>
借鉴:https://blog.youkuaiyun.com/qq_35352552/article/details/64918980?utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-4.control&dist_request_id=&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-4.control