打造一个城市选择页面

作者: AlphaGao | 来源:发表于2017-07-25 13:00 被阅读290次

又是很久没有写文章了,不写文章的这段日子里,感觉生活毫无乐趣,没有什么成就感,以后还是要多写啊,至少一周一篇吧。

需求

城市选择页面是很多 App 都有的组件,比如美团、大众点评之类的,而这个文章就是模仿美团的城市选择组件打造的,不过比起美团还是有差距的。
主要的需求有以下几点:

  1. 显示当前城市;
  2. 显示设备定位城市;
  3. 按照城市拼音进行排序和分类
  4. 城市首字母快速导航
  5. 城市搜索,关键字高亮

由于显示定位城市需要使用到第三方地图 SDK,为了专注的实现界面效果,这里就不具体实现了,模拟一下即可。

设计

根据需求来看,城市选择页面可以分为这么几个部分:

  • 搜索栏
  • 城市列表
  • 首字母索引导航
  • 搜索结果列表

为了更好的利用屏幕空间,把搜索栏与城市列表放在一起,也就是在同一个 RecyclerView 中。

差不多就是下面的样子:

  • 由于列表包含不同的布局,需要定义多个 ViewType
    • Type_Search 搜索栏
    • Type_Current 当前城市
    • Type_Loc_title 定位城市标题
    • Type_Loc_city 定位城市
    • Type_letter_index 首字母标题
    • Type_City 城市名

实现

布局

从上面的图很容易就知道,位置处于 0 ~ 3 的 ViewType 已经确定了,那如何确定城市和城市首字母索引对应位置的 ViewType 呢?简单,暴力匹配即可:

    @Override
    public int getItemViewType(int position) {
        if (position == 0) {
            return TYPE_SEARCH;//搜索栏
        } else if (position == 1) {
            return TYPE_CURRENT;//当前城市
        } else if (position == 2) {
            return TYPE_LOC_TITLE;//定位城市标签
        } else if (position == 3) {
            return TYPE_LOC_CITY;//定位城市
        }

        List<String> letters = new ArrayList<>();
        letters.add(cityList.get(0).getSurName());
        for (int i = 0; i < cityList.size(); i++) {
            if (!letters.contains(cityList.get(i).getSurName())) {
                letters.add(cityList.get(i).getSurName());
            }
            if (4 + letters.size() + i - 1 == position) {
                return TYPE_LETTER;
            }
            if (4 + letters.size() + i == position) {
                return TYPE_CITY;
            }
        }
        return super.getItemViewType(position);
    }

也就是遍历城市列表,先保存第一个城市的首字母到索引列表,然后每遍历一个城市,判断其首字母是否已经在索引列表中,存在就跳过,当前位置就是城市视图,不存在就加入首字母到索引,当前位置就是这个字母索引视图了。

这么一来,就很容易知道所有视图的数量了:

    @Override
    public int getItemCount() {
        if (cityList == null || cityList.size() == 0) {
            return 4;
        }
        int letterCount = getLetterCount();
        return letterCount + cityList.size() + 4;
    }

即总数=城市数量+字母索引的数量+顶部的几个视图。

字母索引的数量可以通过遍历城市列表获取:

    private int getLetterCount() {
        letters = new ArrayList<>();
        for (City c : cityList) {
            if (!letters.contains(c.getSurName())) {
                letters.add(c.getSurName());
            }
        }
        return letters.size();
    }
索引冻结

列表滑动的时候,最上面的城市的首字母索引要停留在顶部,继续滑动就被下面的另一个城市列表的字母代替,这里体现为顶上去和压下来的效果,其实就是监听列表的滑动额外控制一个 View 层的滑动。
为列表设置 OnScrollListener ,在 onScrolled 方法中作出响应:

    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        lockTopIndex(dy);
    }

    private void lockTopIndex(int dy) {
        mSuspensionHeight = indexViewTv.getHeight();
        int pos = layoutManager.findFirstVisibleItemPosition();
        hideOrShow(pos);
        if (dy > 0) {//向上滑动的时候,下面的索引将上面的索引顶出去
            if (adapter != null) {
                View view = layoutManager.findViewByPosition(pos + 1);
                if (view != null && adapter.getItemViewType(pos + 1) == CityAdapter.TYPE_LETTER) {
                    if (view.getTop() <= mSuspensionHeight) {
                        indexViewTv.setY(-(mSuspensionHeight - view.getTop()));
                    } else {
                        indexViewTv.setY(0);
                    }
                }
            }
        } else {//向下滑动的时候,上面的索引将下面的索引压下来
            if (adapter != null && pos >= 2) {
                int type = adapter.getItemViewType(pos);
                if (type == CityAdapter.TYPE_CITY
                        || type == CityAdapter.TYPE_LOC_CITY) {
                    View view = layoutManager.findViewByPosition(pos);//目标字母索引
                    if (view != null) {
                        if (view.getBottom() >= 0 && view.getBottom() <= mSuspensionHeight) {
                            if (adapter.getItemViewType(pos) != adapter.getItemViewType(pos + 1)) {
                                //跟随目标逐渐上移
                                indexViewTv.setY(view.getBottom() - mSuspensionHeight);
                            }
                        } else {
                            //将悬浮索引归位
                            indexViewTv.setY(0);
                        }
                        updateIndexText(pos - 1);
                    }
                }
            }
        }
        if (mCurrentPosition != pos) {
            mCurrentPosition = pos;
            indexViewTv.setY(0);
            if (dy > 0) {
                updateIndexText(mCurrentPosition);
            }
        }
    }

    /**
     * 根据当前可见 item 的位置判断是否要隐藏顶部悬浮索引
     *
     * @param pos 第一个可见 item 的位置
     */
    private void hideOrShow(int pos) {
        if (pos == 0 || pos == 1) {
            indexViewTv.setVisibility(View.GONE);
        } else {
            indexViewTv.setVisibility(View.VISIBLE);
        }
    }

    /**
     * 根据 RecyclerView 的位置设置正确的悬浮索引内容
     *
     * @param pos 第一个可见 item 的位置
     */
    private void updateIndexText(int pos) {
        String s = adapter.getIndexStrFromPosition(pos);
        if (s != null) {
            indexViewTv.setText(s);
        }
    }

可以用于当做悬浮在顶部的索引的 ViewType 只有 Type_Loc_title 和 Type_letter_index ,因此需要判断首个可见 item 是否处于这两者及其知识内容的范围以内,也就是首个可见 item 是否是 Type_city 或者 Type_Current_City。
在下面一个索引距离顶部一个索引的高度的时候,将悬浮索引盖在顶部索引的上面,随着下面的索引的移动同时向上移动,即模拟被顶上去的效果,当下面这个索引完全到达顶部的时候,悬浮索引也被完全移出去了,此时再将悬浮索引盖在现在这个索引的上面,就是新的索引了。
压下来的效果同理,把悬浮索引放在当前第一个索引的顶部,随着可见索引的移动而移动,当可见的索引移动到距离顶部一个索引视图的距离的时候,停止悬浮索引的移动,就是前一个索引了。

搜索

由于搜索栏是在 adapter 中初始化的,直接在这个视图的基础操作并不方便,因此在点击搜索栏的时候由 Activity 重新操作一层 View 用于搜索交互,很多 App 也都是这么做的,包括美团,如果为了视觉体验更好,就需要添加过度动画,我这里就省了。

    private void setSearchBar(SearchHolder holder) {
        holder.searchBar.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Message msg = Message.obtain();
                msg.what = Event.SEARCH_CITY;
                EventManager.getInstance().publishEvent(msg);
            }
        });
    }

CityActivity.class

    @Override
    public void onNewEvent(Message msg) {
        if (msg.what == Event.CITY_CHOOSE_OK) {
            String cityId = (String) msg.obj;
            changeCurrentCity(cityId);
        } else if (msg.what == Event.SEARCH_CITY) {
            searchLayout.setVisibility(View.VISIBLE);
            searchBar.requestFocus();
            inputMethodManager.showSoftInput(searchBar, 0);
        }
    }


搜索栏被点击的时候,向宿主 Activity 发送一条消息,表示开启搜索交互。
searchLayout 是盖在普通视图上面的一层,不进行搜索交互的时候是隐藏的,收到消息后便显示出来。

关键字高亮

这个就比较简单了,也不需要正则匹配,简单匹配即可, SpannableStringBuilder 是可以直接作为 text 被设置的:

    /**
     * 高亮显示列表中的搜索关键字
     *
     * @param searchStr 搜索关键字
     * @param txt       全部文本
     * @return 含高亮的文本
     */
    private SpannableStringBuilder setSearchStrHighLight(String searchStr, String txt) {
        SpannableStringBuilder builder = new SpannableStringBuilder(txt);
        Pattern p = Pattern.compile(searchStr);
        Matcher matcher = p.matcher(txt);
        while (matcher.find()) {
            builder.setSpan(new ForegroundColorSpan(
                            getResources().getColor(R.color.colorPrimary)),
                    matcher.start(), matcher.end(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        return builder;
    }
字母快速导航

也就是右侧的字母触摸导航,需要自定义 View 实现,也是个比较简单的自定义 View:

public class LetterIndexView extends View {

    private static final String TAG = "LetterIndexView";

    private List<String> indexs = Arrays.asList("#", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
            "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
            "U", "V", "W", "X", "Y", "Z");
    private Paint paint;

    private int cellWidth;
    private int cellHeight;

    private int curIndex = -1;
    private OnIndexChangeListener mListener;
    private int paddingLeft;
    private int paddingRight;
    private int paddingTop;
    private int paddingBottom;

    public void setIndexs(List<String> indexs) {
        this.indexs = indexs;
        invalidate();
    }

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

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

    public LetterIndexView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        paint = new Paint();
        paint.setColor(getResources().getColor(R.color.colorPrimary));
        paint.setAntiAlias(true);
        paint.setTextSize(Utils.dp2px(12));
        paint.setFakeBoldText(true);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        paddingLeft = getPaddingLeft();
        paddingRight = getPaddingRight();
        paddingTop = getPaddingTop();
        paddingBottom = getPaddingBottom();
        cellWidth = getMeasuredWidth() - paddingLeft - paddingRight;
        cellHeight = (getMeasuredHeight() - paddingTop - paddingBottom) / indexs.size();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            //默认宽高
            setMeasuredDimension(Utils.dp2px(20), Utils.dp2px(17) * indexs.size());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(Utils.dp2px(20), heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, Utils.dp2px(17) * indexs.size());
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        Log.d(TAG, "onDraw: ");
        for (int i = 0; i < indexs.size(); i++) {
            String c = indexs.get(i);
            Rect bound = new Rect();
            paint.getTextBounds(c, 0, c.length(), bound);
            int x = (cellWidth - bound.width()) / 2 + paddingLeft;
            int y = i * cellHeight + (cellHeight + bound.height()) / 2 + paddingTop;
            canvas.drawText(c, x, y, paint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                updateIndex(event);
                break;
            case MotionEvent.ACTION_MOVE:
                updateIndex(event);
                break;
            case MotionEvent.ACTION_UP:
                curIndex = -1;
                break;
        }

        return true;
    }

    private void updateIndex(MotionEvent event) {
        int y = (int) event.getY();
        int index = y / cellHeight;
        if (index >= 0 && index < indexs.size()) {
            if (index != curIndex) {
                curIndex = index;
                if (mListener != null) {
                    mListener.onIndexChanged(indexs.get(index));
                }
            }
        }
    }

    public void setOnIndexChangeListener(OnIndexChangeListener listener) {
        mListener = listener;
    }

    public interface OnIndexChangeListener {
        void onIndexChanged(String index);
    }
}

索引导航有默认的显示内容,也可以自定义重新绘制。在触摸按下和移动的时候计算出触摸的索引位置然后通过 listener 通知到宿主 Activity 更改城市列表的内容即可,功能很简单。

    indexView.setOnIndexChangeListener(new LetterIndexView.OnIndexChangeListener() {
        @Override
        public void onIndexChanged(String index) {
            updateCityView(index);
        }
    });

    /**
     * 根据右侧字母导航快速变换可见范围
     *
     * @param index 导航内容
     */
    private void updateCityView(String index) {
        LinearLayoutManager manager = (LinearLayoutManager) cityLayout.getLayoutManager();
        if (index.equals("#")) {
            manager.scrollToPositionWithOffset(0, 0);
        }
        if (index.equals("!")) {
            manager.scrollToPositionWithOffset(2, 0);
        }
        if (cityList != null && cityList.size() > 0) {
            //通过比较确定目标位置
            List<String> list = new ArrayList<>();
            int pos = 0;
            for (int i = 0; i < cityList.size(); i++) {
                if (!list.contains(cityList.get(i).getSurName())) {
                    list.add(cityList.get(i).getSurName());
                    pos = i;
                }
                if (list.get(list.size() - 1).equals(index)) {
                    manager.scrollToPositionWithOffset(4 + list.size() + pos - 1, 0);
                }
            }
        }
        //延迟更改顶部悬浮索引的内容,否则会在内容没有完全更新之前设置,导致索引不搭配
        cityLayout.post(new Runnable() {
            @Override
            public void run() {
                updateIndexText(layoutManager.findFirstVisibleItemPosition());
            }
        });
    }

最终效果:

Summary

功能实现基本上就是这样,但是这样的实现方式其实并不是很好,现在都讲究组件化,这样的一个功能如果能够封装成独立的组件,即用即插,使用的方便性会很好多。但是封装涉及到页面的显示效果,城市对象的 POJO 类,要封装成符合所有 App 风格和需求就没那么容易了。

本文最早发布于alphagao.com

相关文章

网友评论

    本文标题:打造一个城市选择页面

    本文链接:https://www.haomeiwen.com/subject/ihewkxtx.html