打造一個(gè)城市選擇頁面

又是很久沒有寫文章了誊酌,不寫文章的這段日子里,感覺生活毫無樂趣露乏,沒有什么成就感碧浊,以后還是要多寫啊,至少一周一篇吧瘟仿。

需求

城市選擇頁面是很多 App 都有的組件箱锐,比如美團(tuán)、大眾點(diǎn)評之類的劳较,而這個(gè)文章就是模仿美團(tuán)的城市選擇組件打造的驹止,不過比起美團(tuán)還是有差距的浩聋。
主要的需求有以下幾點(diǎn):

  1. 顯示當(dāng)前城市;
  2. 顯示設(shè)備定位城市幢哨;
  3. 按照城市拼音進(jìn)行排序和分類
  4. 城市首字母快速導(dǎo)航
  5. 城市搜索赡勘,關(guān)鍵字高亮

由于顯示定位城市需要使用到第三方地圖 SDK,為了專注的實(shí)現(xiàn)界面效果捞镰,這里就不具體實(shí)現(xiàn)了闸与,模擬一下即可。

設(shè)計(jì)

根據(jù)需求來看岸售,城市選擇頁面可以分為這么幾個(gè)部分:

  • 搜索欄
  • 城市列表
  • 首字母索引導(dǎo)航
  • 搜索結(jié)果列表

為了更好的利用屏幕空間践樱,把搜索欄與城市列表放在一起,也就是在同一個(gè) RecyclerView 中凸丸。

差不多就是下面的樣子:

  • 由于列表包含不同的布局拷邢,需要定義多個(gè) ViewType
    • Type_Search 搜索欄
    • Type_Current 當(dāng)前城市
    • Type_Loc_title 定位城市標(biāo)題
    • Type_Loc_city 定位城市
    • Type_letter_index 首字母標(biāo)題
    • Type_City 城市名

實(shí)現(xiàn)

布局

從上面的圖很容易就知道,位置處于 0 ~ 3 的 ViewType 已經(jīng)確定了屎慢,那如何確定城市和城市首字母索引對應(yīng)位置的 ViewType 呢瞭稼?簡單,暴力匹配即可:

    @Override
    public int getItemViewType(int position) {
        if (position == 0) {
            return TYPE_SEARCH;//搜索欄
        } else if (position == 1) {
            return TYPE_CURRENT;//當(dāng)前城市
        } else if (position == 2) {
            return TYPE_LOC_TITLE;//定位城市標(biāo)簽
        } 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);
    }

也就是遍歷城市列表腻惠,先保存第一個(gè)城市的首字母到索引列表环肘,然后每遍歷一個(gè)城市,判斷其首字母是否已經(jīng)在索引列表中集灌,存在就跳過悔雹,當(dāng)前位置就是城市視圖,不存在就加入首字母到索引欣喧,當(dāng)前位置就是這個(gè)字母索引視圖了腌零。

這么一來,就很容易知道所有視圖的數(shù)量了:

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

即總數(shù)=城市數(shù)量+字母索引的數(shù)量+頂部的幾個(gè)視圖唆阿。

字母索引的數(shù)量可以通過遍歷城市列表獲纫娼А:

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

列表滑動(dòng)的時(shí)候,最上面的城市的首字母索引要停留在頂部驯鳖,繼續(xù)滑動(dòng)就被下面的另一個(gè)城市列表的字母代替饰躲,這里體現(xiàn)為頂上去和壓下來的效果,其實(shí)就是監(jiān)聽列表的滑動(dòng)額外控制一個(gè) View 層的滑動(dòng)臼隔。
為列表設(shè)置 OnScrollListener ,在 onScrolled 方法中作出響應(yīng):

    @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) {//向上滑動(dòng)的時(shí)候妄壶,下面的索引將上面的索引頂出去
            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 {//向下滑動(dòng)的時(shí)候摔握,上面的索引將下面的索引壓下來
            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);//目標(biāo)字母索引
                    if (view != null) {
                        if (view.getBottom() >= 0 && view.getBottom() <= mSuspensionHeight) {
                            if (adapter.getItemViewType(pos) != adapter.getItemViewType(pos + 1)) {
                                //跟隨目標(biāo)逐漸上移
                                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);
            }
        }
    }

    /**
     * 根據(jù)當(dāng)前可見 item 的位置判斷是否要隱藏頂部懸浮索引
     *
     * @param pos 第一個(gè)可見 item 的位置
     */
    private void hideOrShow(int pos) {
        if (pos == 0 || pos == 1) {
            indexViewTv.setVisibility(View.GONE);
        } else {
            indexViewTv.setVisibility(View.VISIBLE);
        }
    }

    /**
     * 根據(jù) RecyclerView 的位置設(shè)置正確的懸浮索引內(nèi)容
     *
     * @param pos 第一個(gè)可見 item 的位置
     */
    private void updateIndexText(int pos) {
        String s = adapter.getIndexStrFromPosition(pos);
        if (s != null) {
            indexViewTv.setText(s);
        }
    }

可以用于當(dāng)做懸浮在頂部的索引的 ViewType 只有 Type_Loc_title 和 Type_letter_index ,因此需要判斷首個(gè)可見 item 是否處于這兩者及其知識(shí)內(nèi)容的范圍以內(nèi)丁寄,也就是首個(gè)可見 item 是否是 Type_city 或者 Type_Current_City氨淌。
在下面一個(gè)索引距離頂部一個(gè)索引的高度的時(shí)候泊愧,將懸浮索引蓋在頂部索引的上面,隨著下面的索引的移動(dòng)同時(shí)向上移動(dòng)盛正,即模擬被頂上去的效果删咱,當(dāng)下面這個(gè)索引完全到達(dá)頂部的時(shí)候,懸浮索引也被完全移出去了豪筝,此時(shí)再將懸浮索引蓋在現(xiàn)在這個(gè)索引的上面痰滋,就是新的索引了。
壓下來的效果同理续崖,把懸浮索引放在當(dāng)前第一個(gè)索引的頂部敲街,隨著可見索引的移動(dòng)而移動(dòng),當(dāng)可見的索引移動(dòng)到距離頂部一個(gè)索引視圖的距離的時(shí)候严望,停止懸浮索引的移動(dòng)多艇,就是前一個(gè)索引了。

搜索

由于搜索欄是在 adapter 中初始化的像吻,直接在這個(gè)視圖的基礎(chǔ)操作并不方便峻黍,因此在點(diǎn)擊搜索欄的時(shí)候由 Activity 重新操作一層 View 用于搜索交互,很多 App 也都是這么做的拨匆,包括美團(tuán)姆涩,如果為了視覺體驗(yàn)更好,就需要添加過度動(dòng)畫涮雷,我這里就省了阵面。

    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);
        }
    }


搜索欄被點(diǎn)擊的時(shí)候,向宿主 Activity 發(fā)送一條消息洪鸭,表示開啟搜索交互样刷。
searchLayout 是蓋在普通視圖上面的一層,不進(jìn)行搜索交互的時(shí)候是隱藏的览爵,收到消息后便顯示出來置鼻。

關(guān)鍵字高亮

這個(gè)就比較簡單了,也不需要正則匹配蜓竹,簡單匹配即可, SpannableStringBuilder 是可以直接作為 text 被設(shè)置的:

    /**
     * 高亮顯示列表中的搜索關(guān)鍵字
     *
     * @param searchStr 搜索關(guān)鍵字
     * @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;
    }
字母快速導(dǎo)航

也就是右側(cè)的字母觸摸導(dǎo)航箕母,需要自定義 View 實(shí)現(xiàn),也是個(gè)比較簡單的自定義 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) {
            //默認(rèn)寬高
            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);
    }
}

索引導(dǎo)航有默認(rèn)的顯示內(nèi)容俱济,也可以自定義重新繪制嘶是。在觸摸按下和移動(dòng)的時(shí)候計(jì)算出觸摸的索引位置然后通過 listener 通知到宿主 Activity 更改城市列表的內(nèi)容即可,功能很簡單蛛碌。

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

    /**
     * 根據(jù)右側(cè)字母導(dǎo)航快速變換可見范圍
     *
     * @param index 導(dǎo)航內(nèi)容
     */
    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) {
            //通過比較確定目標(biāo)位置
            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);
                }
            }
        }
        //延遲更改頂部懸浮索引的內(nèi)容聂喇,否則會(huì)在內(nèi)容沒有完全更新之前設(shè)置,導(dǎo)致索引不搭配
        cityLayout.post(new Runnable() {
            @Override
            public void run() {
                updateIndexText(layoutManager.findFirstVisibleItemPosition());
            }
        });
    }

最終效果:

Summary

功能實(shí)現(xiàn)基本上就是這樣,但是這樣的實(shí)現(xiàn)方式其實(shí)并不是很好希太,現(xiàn)在都講究組件化克饶,這樣的一個(gè)功能如果能夠封裝成獨(dú)立的組件,即用即插誊辉,使用的方便性會(huì)很好多矾湃。但是封裝涉及到頁面的顯示效果,城市對象的 POJO 類堕澄,要封裝成符合所有 App 風(fēng)格和需求就沒那么容易了邀跃。

本文最早發(fā)布于alphagao.com

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市奈偏,隨后出現(xiàn)的幾起案子坞嘀,更是在濱河造成了極大的恐慌,老刑警劉巖惊来,帶你破解...
    沈念sama閱讀 206,482評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件丽涩,死亡現(xiàn)場離奇詭異,居然都是意外死亡裁蚁,警方通過查閱死者的電腦和手機(jī)矢渊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,377評論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來枉证,“玉大人矮男,你說我怎么就攤上這事∈已瑁” “怎么了毡鉴?”我有些...
    開封第一講書人閱讀 152,762評論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長秒赤。 經(jīng)常有香客問我猪瞬,道長,這世上最難降的妖魔是什么入篮? 我笑而不...
    開封第一講書人閱讀 55,273評論 1 279
  • 正文 為了忘掉前任陈瘦,我火速辦了婚禮,結(jié)果婚禮上潮售,老公的妹妹穿的比我還像新娘痊项。我一直安慰自己,他們只是感情好酥诽,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,289評論 5 373
  • 文/花漫 我一把揭開白布鞍泉。 她就那樣靜靜地躺著,像睡著了一般肮帐。 火紅的嫁衣襯著肌膚如雪咖驮。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,046評論 1 285
  • 那天,我揣著相機(jī)與錄音游沿,去河邊找鬼。 笑死肮砾,一個(gè)胖子當(dāng)著我的面吹牛诀黍,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播仗处,決...
    沈念sama閱讀 38,351評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼眯勾,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了婆誓?” 一聲冷哼從身側(cè)響起吃环,我...
    開封第一講書人閱讀 36,988評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎洋幻,沒想到半個(gè)月后郁轻,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,476評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡文留,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,948評論 2 324
  • 正文 我和宋清朗相戀三年好唯,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片燥翅。...
    茶點(diǎn)故事閱讀 38,064評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡骑篙,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出森书,到底是詐尸還是另有隱情靶端,我是刑警寧澤,帶...
    沈念sama閱讀 33,712評論 4 323
  • 正文 年R本政府宣布凛膏,位于F島的核電站杨名,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏译柏。R本人自食惡果不足惜镣煮,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,261評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望鄙麦。 院中可真熱鬧典唇,春花似錦、人聲如沸胯府。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,264評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽骂因。三九已至炎咖,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背乘盼。 一陣腳步聲響...
    開封第一講書人閱讀 31,486評論 1 262
  • 我被黑心中介騙來泰國打工升熊, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人绸栅。 一個(gè)月前我還...
    沈念sama閱讀 45,511評論 2 354
  • 正文 我出身青樓级野,卻偏偏與公主長得像,于是被迫代替她去往敵國和親粹胯。 傳聞我的和親對象是個(gè)殘疾皇子蓖柔,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,802評論 2 345

推薦閱讀更多精彩內(nèi)容