在开发中很常见一些选择器,例如地址选择器、性别选择器等等,这里主要讲的是如何自定义一个选择器~

最近(2022.9)要离开当前公司了,正好有时间好好整理一下最近的收获,向大牛们再奋进奋进,10.24留下此篇留念 ~

因为是新项目,所以一切从零开始,开发时间比较紧凑,用到了很多三方框架,其中关于选择器控件 我之前也记录过一些,这次是直接从github又找到了 AndroidPicker


待优化

  • 前期了解
  • 准备工作
  • 核心控件
  • 使用方式


关于最终自定义实现的地址选择器效果(切记这里仅是示例,完全可以自定义同等类型的View),之后有机会我会进行补全...

前期了解

虽然说是实现一款自定义的选择器控件,但是终究站在了别人肩膀上,我用了俩款三方框架 ,然后梳理内部、外部逻辑得以使用!

我一开始用的是 AndroidPicker,但是因为无法满足需求才开始自定义选择器的,所以我在篇内用的 WheelView 是 AndroidPicker 对应包下的内容,但其本质还是用的 WheelView ,然后我还用到了 XPopup 优化弹窗效果

其实要更好的自定义选择器的话,最好要了解一下 WheelView ,但是关于WheelView的使用文档描述不太详细,有机会我会补充一下 ~

准备工作

build(app) - 引入依赖

implementation "com.contrarywind:Android-PickerView:4.1.9"
 implementation 'com.github.li-xiaojun:XPopup:2.8.14'

AddressPickerEntity - 将选中数据传到选择器时用到的实体类,记得实例化

package com.xxx.xx.bean

import java.io.Serializable

open class AddressPickerEntity : Serializable {
    /**
     * 省
     */
    var province: String = ""

    /**
     * 省 id
     */
    var provinceId: String = ""

    /**
     * 市
     */
    var city: String = ""

    /**
     * 市 id
     */
    var cityId: String = ""

    /**
     * 区
     */
    var county: String = ""

    /**
     * 区 id
     */
    var countyId: String = ""

    /**
     * 是否显示区
     */
    var disState: Boolean = false

}

AddressResponse - 省市区json对应的数据实体类(每个人可能都不同,具体看后台吧)

package com.xxx.xx.model.bean

import java.io.Serializable

data class AddressResponse(
    val child: List<Child>,
    val firstLetter: String,
    val id: String,
    val level: Int,
    val name: String,
    val parentId: Int,
    val part: Int
) : Serializable {
    data class Child(
        val child: List<ChildX>,
        val firstLetter: String,
        val id: String,
        val level: Int,
        val name: String,
        val parentId: Int,
        val part: Int
    ) : Serializable {
        data class ChildX(
            val child: List<Any>,
            val firstLetter: String,
            val id: String,
            val level: Int,
            val name: String,
            val parentId: Int,
            val part: Int
        ) : Serializable
    }
}

SimpleCallBack - 选择器选中的回调接口

package com.xxx.xx.view.popup

interface SimpleCallBack<T> {
    fun call(t: T)
}

核心控件

这里就是我们的核心了:开始自定义一个属于自己的选择器

PickerAddressPopup

package com.xxx.xx.view.popup

import android.content.Context
import android.graphics.Color
import android.widget.TextView
import com.bigkoo.pickerview.adapter.ArrayWheelAdapter
import com.contrarywind.view.WheelView
import com.jsmedia.jsmanager.R
import com.jsmedia.jsmanager.bean.AddressPickerEntity
import com.jsmedia.jsmanager.model.bean.AddressResponse
import com.lxj.xpopup.XPopup
import com.lxj.xpopup.core.BasePopupView
import com.lxj.xpopup.core.BottomPopupView
import com.lxj.xpopup.enums.PopupPosition

/**
 * 定制化地址选择
 */
class PickerAddressPopup : BottomPopupView {
    private var mContext: Context = context
    private var popupView: BasePopupView? = null
    private var wl_province: WheelView? = null
    private var wl_city: WheelView? = null
    private var wl_county: WheelView? = null
    var provinceList = ArrayList<AddressResponse>()
    //省、市、区对应列表
    private val provinceStrList = ArrayList<String>()
    private var cityStrList = ArrayList<String>()
    private var countyStrList = ArrayList<String>()
    //默认数据
    private var province: String = "上海市"
    private var city: String = "上海市"
    private var county: String = "浦东新区"
    private var cityId: String = ""
    private var curProvincePos = 0
    private var curCityPos = 0
    private var curCountyPos = 0

    var simpleCallBack: SimpleCallBack<AddressPickerEntity>? = null

    var intentAddress: AddressPickerEntity? = null

    /**
     * @param context 上下文
     * @param intentAddress 用户传递所选数据的实体类,
     * @param intentList 省市区数据(可以是后台返回的json格式对应数据,也可以做本地)
     * @param simpleCallBack 选择后的结果回调
     * */
    constructor(
        context: Context,
        intentAddress: AddressPickerEntity,
        intentList: ArrayList<AddressResponse>,
        simpleCallBack: SimpleCallBack<AddressPickerEntity>
    ) : super(context) {
        this.simpleCallBack = simpleCallBack
        this.provinceList = intentList
        this.intentAddress = intentAddress
    }

    override fun onCreate() {
        super.onCreate()
        wl_province = findViewById(R.id.wl_province)
        wl_city = findViewById(R.id.wl_city)
        wl_county = findViewById(R.id.wl_county)
        //WheelView的setCyclic方法:主要看是否要允许内部数据无限滚动
        wl_province?.setCyclic(false)
        wl_city?.setCyclic(false)
        wl_county?.setCyclic(false)

        initData()

        wl_province?.adapter = ArrayWheelAdapter(provinceStrList)
        wl_city?.adapter = ArrayWheelAdapter(cityStrList)
        wl_county?.adapter = ArrayWheelAdapter(countyStrList)
        
        addListener()
    }

    private fun addListener() {
        findViewById<TextView>(R.id.tv_cancel).setOnClickListener {
            dismiss()
        }

		//用户点击确定后的逻辑,主要用于回调选中省、市、区结果
        findViewById<TextView>(R.id.tv_verify).setOnClickListener {
            val addressEntity = AddressPickerEntity()
            addressEntity.province = province
            addressEntity.city = city
            addressEntity.county = county
            addressEntity.cityId = cityId
            /*  timeEntity.weeks = weekAdapter.getWeekSelected()
              timeEntity.yTime = TimePickerEntity.Time(yyHour, yyMinute)
              timeEntity.xTime = TimePickerEntity.Time(xyHour, xyMinute)*/
              
            //Kt 接口回调
            simpleCallBack?.call(addressEntity)
            dismiss()
        }

		//市:WheelView 选中后的逻辑
        wl_province?.setOnItemSelectedListener {
            province = provinceStrList[it]

            //var cityChild = addressList[it].child as ArrayList<AddressResponse.Child>
            var cityChild = provinceList[it].child as ArrayList<AddressResponse.Child>
            /* cityList.clear()
             cityList.addAll(cityChild)*/

            city = cityChild[0].name
            cityId = cityChild[0].id

            if (cityChild.isNotEmpty()) {
                cityStrList.clear()
                for (i in cityChild.indices) {
                    cityStrList.add(cityChild[i].name)
                }

                var countyChild = cityChild[0].child
                if (countyChild.isNotEmpty()) {
                    countyStrList.clear()
                    for (i in countyChild.indices) {
                        countyStrList.add(countyChild[i].name)
                    }
                }
            }

            curCityPos = 0
            curCountyPos = 0
            curProvincePos = it
            wl_city?.currentItem = curCityPos
            wl_county?.currentItem = curCountyPos
            wl_city?.adapter = ArrayWheelAdapter(cityStrList)
            wl_county?.adapter = ArrayWheelAdapter(countyStrList)
        }
		
		//市:WheelView 选中后的逻辑
        wl_city?.setOnItemSelectedListener {
            city = cityStrList[it]
            var child = provinceList[curProvincePos].child[it].child

            if (child.isNotEmpty()) {
                countyStrList.clear()
                for (i in child.indices) {
                    countyStrList.add(child[i].name)
                }
            }
            cityId = provinceList[curProvincePos].child[it].id
            curCountyPos = 0
            wl_county?.currentItem = curCountyPos
            wl_county?.adapter = ArrayWheelAdapter(countyStrList)
        }

        wl_county?.setOnItemSelectedListener {
            county = countyStrList[it]
        }
    }

	/**
	* 初始化数据、数据定位
	*/
    private fun initData() {
        provinceStrList.clear()
        cityStrList.clear()
        countyStrList.clear()

        //WheelView的setItemsVisibleCount、setDividerColor方法:主要设置内部显示数据、颜色
        wl_province?.setItemsVisibleCount(10)
        wl_province?.setDividerColor(Color.TRANSPARENT)
        wl_city?.setItemsVisibleCount(10)
        wl_city?.setDividerColor(Color.TRANSPARENT)
        wl_county?.setItemsVisibleCount(10)
        wl_county?.setDividerColor(Color.TRANSPARENT)

		//查看外部传递的数据,之后用于定位选择器的选中效果
        if (!intentAddress?.province.isNullOrEmpty()) {
            province = intentAddress?.province.toString()
        }
        if (!intentAddress?.city.isNullOrEmpty()) {
            city = intentAddress?.city.toString()
        }
        if (!intentAddress?.cityId.isNullOrEmpty()) {
            cityId = intentAddress?.cityId.toString()
        }
        if (!intentAddress?.county.isNullOrEmpty()) {
            county = intentAddress?.county.toString()
        }

        /*    province = intentAddress?.province.toString()
            city = intentAddress?.city.toString()
            county = intentAddress?.county.toString()
            cityId = intentAddress?.cityId.toString()*/
            
		//扩展方法:因为有的需求是显示省、市,有的要显示省、市、区
        if (intentAddress?.disState == true) {
            wl_county?.visibility = VISIBLE
        } else {
            wl_county?.visibility = GONE
        }

        /*curProvincePos = 0
        curCityPos = 0
        curCountyPos = 0*/

		//通过外部传递进来的 “省名称” 定位到对应 list角标值
        for (i in 0 until provinceList.size) {
            if (provinceList[i].name == province) {
                curProvincePos = i
                break
            }
        }
        
        for (i in 0 until provinceList.size) {
            provinceStrList.add(provinceList[i].name)
        }

        /*    cityList = provinceList[curProvincePos].child as ArrayList<AddressResponse.Child>
            for (i in cityList.indices) {
                if (cityList[i].name == city) {
                    curCityPos = i
                    break
                }
            }
            for (i in 0 until cityList.size) {
                cityStrList.add(cityList[i].name)
            }
    	*/

		//通过外部传递进来的 “市名称” 定位到对应 list角标值
        for (i in provinceList[curProvincePos].child.indices) {
            if (provinceList[curProvincePos].child[i].name == city) {
                curCityPos = i
                break
            }
        }
        
        for (i in 0 until provinceList[curProvincePos].child.size) {
            cityStrList.add(provinceList[curProvincePos].child[i].name)
        }

		//通过外部传递进来的 “区名称” 定位到对应 list角标值
        county.isNotEmpty().run {
            for (i in provinceList[curProvincePos].child[curCityPos].child.indices) {
                if (provinceList[curProvincePos].child[curCityPos].child[i].name == county) {
                    curCountyPos = i
                    break
                }
            }
            for (i in 0 until provinceList[curProvincePos].child[curCityPos].child.size) {
                countyStrList.add(provinceList[curProvincePos].child[curCityPos].child[i].name)
            }
        }

		//及时更新WhellView内部list的角标值
        wl_province?.currentItem = curProvincePos
        wl_city?.currentItem = curCityPos
        wl_county?.currentItem = curCountyPos
    }

    override fun getImplLayoutId(): Int {
        return R.layout.popup_address_picker
    }

    override fun getMaxWidth(): Int {
        return super.getMaxWidth()
    }

    override fun getPopupWidth(): Int {
        return 0
    }

    fun showPopup() {
        if (popupView == null) {
            popupView = XPopup.Builder(mContext).enableDrag(false)
                .autoDismiss(true)
                .popupPosition(PopupPosition.Bottom)
                .asCustom(this)
        }
        popupView?.show()
    }
}

layout.popup_address_picker

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:background="@color/white"
    android:layout_height="wrap_content">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="@dimen/dp_48">

        <androidx.appcompat.widget.AppCompatTextView
            android:id="@+id/tv_cancel"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:gravity="center"
            android:paddingLeft="@dimen/dp_16"
            android:paddingRight="@dimen/dp_16"
            android:text="取消"
            android:textColor="#FF5B5B5B"
            android:textSize="@dimen/sp_16" />

        <androidx.appcompat.widget.AppCompatTextView
            android:id="@+id/tv_verify"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_gravity="right"
            android:gravity="center"
            android:paddingLeft="@dimen/dp_16"
            android:paddingRight="@dimen/dp_16"
            android:text="确定"
            android:textColor="@color/black_title"
            android:textSize="@dimen/sp_16" />
    </FrameLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/white"
        android:orientation="horizontal">

        <com.contrarywind.view.WheelView
            android:id="@+id/wl_province"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="@dimen/dp_300"
            app:wheel_itemTextColor="#AAAAAA"
            app:wheel_itemTextColorSelected="@color/black_title"
            app:wheel_itemTextSize="@dimen/sp_16"
            app:wheel_visibleItemCount="9" />

        <com.contrarywind.view.WheelView
            android:id="@+id/wl_city"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="@dimen/dp_300"
            app:wheel_itemTextColor="#AAAAAA"
            app:wheel_itemTextColorSelected="@color/black_title"
            app:wheel_itemTextSize="@dimen/sp_16"
            app:wheel_visibleItemCount="9" />

        <com.contrarywind.view.WheelView
            android:id="@+id/wl_county"
            android:layout_width="0dp"
            android:layout_weight="1"
            android:layout_height="@dimen/dp_300"
            app:wheel_itemTextColor="#AAAAAA"
            app:wheel_itemTextColorSelected="@color/black_title"
            app:wheel_itemTextSize="@dimen/sp_16"
            app:wheel_visibleItemCount="9" />
    </LinearLayout>
</LinearLayout>

使用方式

早期省市区数据使用的是接口请求的方式,后期沟通过后讲返回数据做了本地处理,直接读本地json就好,有需要可直接前往 → Android进阶 - 存、取、读 本地 Json 文件

伪代码

binding.csCityParent.id -> {
            	//后台返回的省、市、区名称
                var addressPickerEntity = AddressPickerEntity()
                addressPickerEntity.province = province
                addressPickerEntity.city = city
                addressPickerEntity.cityId = cityId
				
				//读取本地省市区数据
        		localAddressList = JsonParseUtil.getLocalAddress(
           			 FileUtil.readAssetsFile(this,"address.json")
       		   ) as ArrayList<AddressResponse>
				
				//创建 - 我们自定义的选择器
                var pickerAddressPopup = PickerAddressPopup(
                    this@PerfectionActivity, addressPickerEntity,
                    localAddressList,
                    object : SimpleCallBack<AddressPickerEntity> {
                        override fun call(info: AddressPickerEntity) {
                        	//自行操作选中的省、市、区即可
                            binding.tvAddress.text = info.province + info.city
                            province = info.province
                            city = info.city
                            cityId = info.cityId
                        }
                    })
                //弹出 - 我们自定义的选择器    
                pickerAddressPopup.showPopup()
            }