自定義一個仿拼多多地址選擇器

前言

公司正在開發一個商城項目,由於項目須要,作了一個仿拼多多的地址選擇器,可是與拼多多實現方法有些出入,大致效果是差很少的。android

(2019年04月22日更新)最後決定仍是單獨提取出來作個demo給你們參考參考,地址:github.com/cyixlq/Addr…git

廢話很少說,先上一張效果動圖: github

效果圖.gif

開始

  1. 先說說本文的一些概念。地區級別:就是好比省級,市級,縣級,鎮級,那麼這種最多就是4級。
  2. 好了,咱們分析一波效果圖,當一個級別的地區選擇好以後會建立出一個新的Tab,到了最後一個地區級別以後就不會再建立新的。若是倒回去從新選擇一個級別的地區,會移除後面的Tab以後再建立一個新的Tab。選擇好以後,若是點擊Tab會切換到相應地區級別,而且滾動到以前選擇的地區顯示,建立新的Tab就默認滾動到第一個position的位置。
  3. 其次,來看看咱們這個界面的佈局:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   android:layout_width="match_parent"
   android:layout_height="560dp"
   android:orientation="vertical"
   android:paddingStart="12dp"
   android:paddingEnd="12dp">
   <!-- Dialog的標題 -->
   <TextView
       android:id="@+id/user_tv_dialog_title"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="18dp"
       android:layout_gravity="center_horizontal"/>
   <!-- 標題下的第一條橫線 -->
   <View
       android:layout_width="match_parent"
       android:layout_height="1dp"
       android:background="#e6e6e6"
       android:layout_marginTop="17dp"/>
   <!-- 頂部的TabLayout -->
   <android.support.design.widget.TabLayout
       android:id="@+id/user_tb_dialog_tab"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       app:tabSelectedTextColor="@color/colorPrimary"
       app:tabGravity="fill"
       app:tabMode="scrollable"/>
   <!-- TabLayout下方的橫線 -->
   <View
       android:layout_width="match_parent"
       android:layout_height="1dp"
       android:background="#e6e6e6"/>
   <!-- 顯示地區數據的RecyclerView -->
   <android.support.v7.widget.RecyclerView
       android:id="@+id/user_rv_dialog_list"
       android:layout_width="match_parent"
       android:layout_height="0dp"
       android:layout_weight="1"/>
</LinearLayout>
複製代碼
  1. 從佈局中咱們能夠看出,我最主要靠TabLayout加RecyclerView實現這個效果,而拼多多我的猜想是TabLayout加RecyclerView加ViewPager,因此拼多多的RecyclerView是能夠側滑到上一個Tab頁或下一個,這也就是和拼多多效果的不一樣之處。

開始擼代碼

  1. 從代碼下手,首先把單個地區列表的佈局寫好:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:paddingTop="10dp"
    android:paddingBottom="10dp"
    tools:ignore="UseCompoundDrawables">
    <!-- 顯示地區名稱 -->
    <TextView
        android:id="@+id/user_tv_address_dialog"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <!-- 顯示後面的勾選圖標 -->
    <ImageView
        android:id="@+id/user_iv_address_dialog"
        android:layout_width="13dp"
        android:layout_height="9dp"
        android:src="@drawable/user_icon_address_check"
        android:layout_marginStart="11dp"
        android:layout_gravity="center_vertical"
        android:visibility="gone"
        tools:ignore="ContentDescription" />
</LinearLayout>
複製代碼
  1. 把地區這個實體對象建立好:
public class AddressItem {
    // 地區名
    private String address;
    // 是否勾選
    private boolean isChecked;
    // 地區的ID,我這邊項目須要的是int型,你們能夠根據本身項目須要進行修改
    private int id;

    public String getAddress() {
        return this.address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    public boolean isChecked() {
        return this.isChecked;
    }

    public void setChecked(boolean checked) {
        this.isChecked = checked;
    }

    public int getId() {
        return this.id;
    }

    public void setId(int id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "AddressItem{" +
                "address='" + address + '\'' + ", isChecked=" + isChecked + ", id=" + id + '}'; } } 複製代碼
  1. 把RecyclerView的適配器寫好:
public class AddressAdapter extends RecyclerView.Adapter<AddressAdapter.MyViewHolder> {
    // 保存地區數據的列表
    private List<AddressItem> list = new ArrayList<>();
    // 自定義的單項被點擊監聽事件
    private ItemClickListener listener;

    @NonNull
    @Override
    public MyViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
        View view = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.user_item_address_bottom_sheet_dialog, viewGroup, false);
        return new MyViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull MyViewHolder myViewHolder, int i) {
        AddressItem item = list.get(i);
        if (item.isChecked()) {
            myViewHolder.tvAddress.setText(item.getAddress());
            myViewHolder.tvAddress.setTextColor(Color.parseColor("#1F83FF"));
            myViewHolder.ivChecked.setVisibility(View.VISIBLE);
        } else {
            myViewHolder.tvAddress.setText(item.getAddress());
            myViewHolder.tvAddress.setTextColor(Color.BLACK);
            myViewHolder.ivChecked.setVisibility(View.GONE);
        }
    }

    @Override
    public int getItemCount() {
        return this.list == null ? 0 : list.size();
    }

    public void setList(List<AddressItem> list) {
        if (this.list != null && list != null) {
            this.list.clear();
            this.list.addAll(list);
            this.notifyDataSetChanged();
        }
    }

    public void setOnItemClickListener(@NonNull ItemClickListener listener) {
        this.listener = listener;
    }

    class MyViewHolder extends RecyclerView.ViewHolder {
        TextView tvAddress;
        ImageView ivChecked;
        MyViewHolder(@NonNull View itemView) {
            super(itemView);
            tvAddress = itemView.findViewById(R.id.user_tv_address_dialog);
            ivChecked = itemView.findViewById(R.id.user_iv_address_dialog);
            if (listener != null) {
                itemView.setOnClickListener(v -> listener.onItemClick(getAdapterPosition()));
            }
        }
    }

    public interface ItemClickListener {
        void onItemClick(int position);
    }
}
複製代碼
  1. 首先本身動手寫了兩個BaseDialog,沒什麼養分,代碼也很簡單:
public abstract class CustomBaseDialog extends Dialog {

    protected Context context;

    public CustomBaseDialog(@NonNull Context context) {
        super(context);
        this.context = context;
    }

    protected abstract Integer getLayout();
    protected abstract Integer getGravity();
    protected abstract Integer getBackgroundRes();
    protected abstract Integer getWindowAnimations();
    protected abstract void initView();


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        if (getLayout() != null)
            setContentView(getLayout());
        Window window = getWindow();
        if (window != null) {
            // 去除DecorView默認的內邊距,好讓佈局佔滿整個橫向屏幕
            View decorView = window.getDecorView();
            decorView.setPadding(0,0,0,0);
            if (getGravity() != null)
                window.setGravity(getGravity());
            else
                window.setGravity(Gravity.CENTER);
            if (getWindowAnimations() != null)
                window.setWindowAnimations(getWindowAnimations());
            if (getBackgroundRes() != null)
                decorView.setBackgroundResource(getBackgroundRes());
        }
        initView();
    }

    protected void setClickListener(int id, View.OnClickListener listener) {
        findViewById(id).setOnClickListener(listener);
    }
}

public abstract class CustomBaseBottomSheetDialog extends CustomBaseDialog {
    public CustomBaseBottomSheetDialog(@NonNull Context context) {
        super(context);
    }

    @Override
    protected Integer getGravity() {
        return Gravity.BOTTOM;
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        Window window = getWindow();
        if (null != window) {
            // 去除window的margin,目的也是爲了讓佈局佔滿屏幕
            WindowManager.LayoutParams layoutParams = window.getAttributes();
            layoutParams.width = WindowManager.LayoutParams.MATCH_PARENT;
            layoutParams.horizontalMargin = 0;
            window.setAttributes(layoutParams);
        }
    }
}
複製代碼
  1. 接着纔是重點,自定義地址選擇器Dialog:
public class AddressBottomSheetDialog extends CustomBaseBottomSheetDialog {

    private TabLayout tabLayout;
    private AddressAdapter addressAdapter;

    private int maxLevel;   // 最大有多少級的地區,能夠經過setMaxLevel方法進行自定義
    private SparseArray<List<AddressItem>> levelList;     // 級別列表數據
    private SparseIntArray levelPosition;                 // 各個級別選中的列表position
    private SparseIntArray levelIds;                      // 各個級別選擇的地址ID
    private String title;  // 標題
    private String tabText = "請選擇";                    // 新的Tab默認顯示的文本
    private TabSelectChangeListener changeListener;       // Tab的選擇被改變的監聽

    public AddressBottomSheetDialog(@NonNull Context context) {
        super(context);
    }

    @Override
    protected Integer getLayout() {
        return R.layout.user_layout_address_bottom_sheet_dialog;
    }

    @Override
    protected Integer getBackgroundRes() {
        return R.drawable.bg_dialog_bottom;
    }

    @Override
    protected Integer getWindowAnimations() {
        return R.style.DialogBottom;
    }

    @Override
    protected void initView() {
        levelList = new SparseArray<>();
        levelPosition = new SparseIntArray();
        levelIds = new SparseIntArray();

        ((TextView)findViewById(R.id.user_tv_dialog_title)).setText(title);
        tabLayout = findViewById(R.id.user_tb_dialog_tab);
        final RecyclerView recyclerView = findViewById(R.id.user_rv_dialog_list);

        tabLayout.addOnTabSelectedListener(new TabLayout.BaseOnTabSelectedListener() {
            @Override
            public void onTabSelected(TabLayout.Tab tab) {
                final int position = tab.getPosition();
                List<AddressItem> list = levelList.get(position);
                if (null != list && !list.isEmpty()) {   // 若是選中級別的List沒有數據就經過執行回調來獲取,不然直接複用
                    addressAdapter.setList(list);
                    final int lastClickPositon = levelPosition.get(position, -1); // 獲取上一次選中的地區的position,若是找不到,默認返回-1
                    if (lastClickPositon >= 0) recyclerView.smoothScrollToPosition(lastClickPositon); // 若是上一次有選擇,RecyclerView滾動到指定position
                } else if (changeListener != null) {
                    // 參數position表明的當前地區級別,父級地區ID應該選當前級別的上一個級別,若是沒有默認返回-1
                    changeListener.onSelectChange(position, levelIds.get(position -1, -1));
                }
            }
            @Override
            public void onTabUnselected(TabLayout.Tab tab) {}
            @Override
            public void onTabReselected(TabLayout.Tab tab) {}
        });
        addressAdapter = new AddressAdapter();
        // 列表單項點擊事件
        addressAdapter.setOnItemClickListener(position -> {
            final int selectedTabPosition = tabLayout.getSelectedTabPosition(); // 選中的Tab的position
            levelIds.put(selectedTabPosition, levelList.get(selectedTabPosition).get(position).getId()); // 更新選中的地區的ID
            changeSelect(selectedTabPosition, position);
            levelPosition.put(selectedTabPosition, position); // 更新選中的地區在列表中的position
            setTabText(selectedTabPosition, levelList.get(selectedTabPosition).get(position).getAddress()); // 將選中的地區的名字顯示在Tab上
            if (selectedTabPosition < maxLevel - 1 && selectedTabPosition == tabLayout.getTabCount() - 1) { // 若是沒達到MaxLevel而且選中的Tab是最後一個就添加一個Tab,而且RecyclerView滾動到最頂部
                tabLayout.addTab(createTab(), true);
                recyclerView.smoothScrollToPosition(0);
            }
        });
        recyclerView.setLayoutManager(new LinearLayoutManager(context));
        recyclerView.setAdapter(addressAdapter);
        tabLayout.addTab(createTab(), true); // 默認添加一個Tab
    }

    // 建立一個請選擇的tab並返回
    private TabLayout.Tab createTab() {
        return tabLayout.newTab().setText(tabText);
    }

    // 當點擊了RecyclerView條目的時候執行的方法
    private void changeSelect(int selectedTabPosition, int nowClickPosition) {
        // 保存下來的當前列表上一個點擊位置.若是找不到該值,默認返回-1
        final int lastPosition = levelPosition.get(selectedTabPosition, -1);
        // 若是上一個點擊位置和下一個點擊位置相同,則不作改變
        if (nowClickPosition == lastPosition) {
            return;
        }
        // 若是不是最後一個而且又從新選擇了級別地區,移除後面的Tab
        final int count = tabLayout.getTabCount();
        // 這裏要倒過來移除Tab,否則會出現這樣的狀況,假如你有四個Tab,你移除第0個,接着移除第一個的話,第一個不是原來的第一個。由於你把第0個移除,原來的第一個就到了第0個的位置上。因此倒過來移除是明智的作法
        if (selectedTabPosition < count - 1) {
            TabLayout.Tab nowTab = tabLayout.getTabAt(selectedTabPosition);
            if (null != nowTab) nowTab.setText(tabText);
            for (int i = count - 1; i > selectedTabPosition; i--) {
                // 將相應地區級別的列表數據移除
                levelList.remove(i);
                // 將以前選中的position重置爲-1
                levelPosition.put(i, -1);
                // 將以前記錄的地區ID重置爲-1
                levelIds.put(i, -1);
                tabLayout.removeTabAt(i);
            }
        }
        // 將如今選擇的地區設置爲已經選中
        levelList.get(selectedTabPosition).get(nowClickPosition).setChecked(true);
        // 經過adapter更新列表單個對象
        addressAdapter.notifyItemChanged(nowClickPosition);
        if (lastPosition >= 0) {
            // 將上一個選中的地區標記爲未選中
            levelList.get(selectedTabPosition).get(lastPosition).setChecked(false);
            // 經過adapter更新列表單個對象
            addressAdapter.notifyItemChanged(lastPosition);
        }
    }
    // 設置第幾個tab的文字
    private void setTabText(int tabPosition, String text) {
        TabLayout.Tab tab = tabLayout.getTabAt(tabPosition);
        if (null != tab) tab.setText(text);
    }




    // -----------------------------  如下是對外公開方法與接口  --------------------------

    /**
     *  設置Dialog的標題
     * @param title 標題文字
     */
    public void setDialogTitle(String title) {
        this.title = title;
    }

    /**
     *  設置在當前tab下還未選擇區域時候tab默認顯示的文字
     * @param tabDefaultText 默認顯示的文字
     */
    public void setTabDefaultText(String tabDefaultText) {
        this.tabText = tabDefaultText;
    }

    /**
     *  設置地址最大級別(如:省,市,縣,鎮的話就是最大4級)
     * @param level 最大級別
     */
    public void setMaxLevel(int level) {
        this.maxLevel = level;
    }

    /**
     *  設置當前級別列表須要顯示的列表數據
     * @param list 列表數據
     * @param level 地區級別
     */
    public void setCurrentAddressList(List<AddressItem> list, int level) {
        levelList.put(level, list);
        addressAdapter.setList(list);
    }

    /**
     *  設置Dialog中Tab點擊切換的監聽
     * @param listener tab切換監聽實現
     */
    public void setTabSelectChangeListener(@NonNull TabSelectChangeListener listener) {
        this.changeListener = listener;
    }

    /**
     *  自定義的Tab切換監聽接口
     */
    public interface TabSelectChangeListener {
        void onSelectChange(int level, int parentId);
    }
}
複製代碼
  1. 使用方法:
private void init() {
    mDialog = new AddressBottomSheetDialog(this);
    mDialog.setDialogTitle("配送至");
    mDialog.setMaxLevel(4);
    mDialog.setTabDefaultText("請選擇");
    mDialog.setTabSelectChangeListener((level, parentId) ->
            mDialog.setCurrentAddressList(requestAddress(level, parentId), level)
    );
    binding.userIvSelectAddress.setOnClickListener(v -> mDialog.show());
}
private List<AddressItem> requestAddress(int level, int parentID) {
    List<AddressItem> list = new ArrayList<>();
    String levelTxt = "未知";
    switch (level) {
        case 0:
            levelTxt = "省級";
            break;
        case 1:
            levelTxt = "市級";
            break;
        case 2:
            levelTxt = "縣級";
            break;
        case 3:
            levelTxt = "鎮級";
    }
    for (int i = 0; i < 32; i++) {
        AddressItem item = new AddressItem();
        item.setChecked(false);
        item.setAddress(levelTxt + i);
        list.add(item);
    }
    return list;
}  
複製代碼

總結

雖然上面的代碼已經有很詳細的註釋,可是仍是有一些東西沒細講,好比SparseArray是什麼等等。bash

  1. SparseArray是什麼?SparseArray後面須要一個泛型,SparseArray<T>,能夠理解爲是HashMap<Integer, T>。可是爲何不用HashMap而使用這個東西?SparseArray是谷歌專門爲安卓打造的Map,優勢是省內存,佔用內存沒HashMap大。以前個人作法是省級列表數據一個list,市級一個list。。。這種寫法,不但耦合度高,用戶也不能自定義最大的地區級別是多少,並且在寫法過程當中少不了各類switch判斷。後來靈機一動,Tab選中的position就是表明的一個級別,直接經過Map來取對應級別的list出來不就行了。
  2. SparseIntArray是什麼?其實它就至關於SparseArray<Integer>,谷歌還爲咱們封裝了其餘基本數據類型的SparseArray,它們就是SparseBooleanArray和SparseLongArray,用法都是類似的。
  3. 爲何不使用一個成員變量來記錄當前選中的tab的position,而後在onTabSelected中更新該成員變量?以前我是這麼作的,可是會出奇怪的問題:在市級從新選擇以後,移除後面的tab後再從新選縣級以後,TabLayout的橫線不會移動到鎮級上了。不知道什麼緣由形成的,猜想多是onTabSelected觸發時機形成選中的Tab的position更新不及時。若是有知道的旁友還望不吝賜教。以下圖:
    出現的問題.gif

20190422更新

  1. 將AddressItem中的ID修改成Object類型,以適配不一樣業務數據,其餘地方也進行了相應的修改
  2. 添加所有地區選擇完成結果回調事件
  3. 修改一些代碼邏輯,有興趣改善的請Pull request
相關文章
相關標籤/搜索