打造一个城市选择页面

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

需求

城市选择页面是很多 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容