自定义一个仿拼多多地址选择器

作者: 06fd4cf1f427 | 来源:发表于2019-04-19 22:33 被阅读106次

    前言

    公司正在开发一个商城项目,因为项目需要,做了一个仿拼多多的地址选择器,但是与拼多多实现方法有些出入,大体效果是差不多的。废话不多说,先上一张效果动图:

    <figcaption></figcaption>

    开始

    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) {
                        changeListener.onSelectChange(position, levelIds.get(position));
                    }
                }
                @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是什么等等。

    1. SparseArray是什么?SparseArray后面需要一个泛型,SparseArray,可以理解为是HashMap<Integer, T>。但是为什么不用HashMap而使用这个东西?SparseArray是谷歌专门为安卓打造的Map,优点是省内存,占用内存没HashMap大。之前我的做法是省级列表数据一个list,市级一个list。。。这种写法,不但耦合度高,用户也不能自定义最大的地区级别是多少,而且在写法过程中少不了各种switch判断。后来灵机一动,Tab选中的position就是代表的一个级别,直接通过Map来取对应级别的list出来不就好了。
    2. SparseIntArray是什么?其实它就相当于SparseArray,谷歌还为我们封装了其他基本数据类型的SparseArray,它们就是SparseBooleanArray和SparseLongArray,用法都是相似的。
    3. 为什么不使用一个成员变量来记录当前选中的tab的position,然后在onTabSelected中更新该成员变量?之前我是这么做的,但是会出奇怪的问题:在市级重新选择之后,移除后面的tab后再重新选县级之后,TabLayout的横线不会移动到镇级上了。不知道什么原因造成的,猜测可能是onTabSelected触发时机造成选中的Tab的position更新不及时。如果有知道的旁友还望不吝赐教。如下图: 出现的问题.gif

    【附】相关架构及资料

    资料领取

    点赞+加群免费获取 Android IOC架构设计

    加群领取获取往期Android高级架构资料、源码、笔记、视频。高级UI、性能优化、架构师课程、NDK、混合式开发(ReactNative+Weex)微信小程序、Flutter全方面的Android进阶实践技术,群内还有技术大牛一起讨论交流解决问题。

    相关文章

      网友评论

        本文标题:自定义一个仿拼多多地址选择器

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