自定义时间选择器并适配遥控器(非触屏)
项目中使用到时间选择器,Android提供原生的DatePickerDialog和TimePickerDialog可以实现,实际使用起来发现对遥控器适配不是很友好,且样式比较单一。
比如这样,触屏版还勉强接受,但对于电视遥控器场景,使用体验简直太差

查阅资料发现可以通过设置主题改变样式
TimePickerDialog(requireContext(), android.R.style.Theme_Material_Dialog_NoActionBar_MinWidth, null )
主题都是只能使用android内置的,且尝试多个主题,发现只有黑色和白色效果(只适配深色或浅色?),并且不支持自定义颜色样式,比如这样


仍然和我们应用的主题色相去甚远,不满足要求。
那么只能选择自定义,未方便叙述分析,后面仅讨论日期选择器吧(时间选择器更简单,不用考虑切换月份年份时天数的变化)。
由于显示效果上是年月日三个要素,首先想到的是recyclerview,因为感官上就是横排的三个列表,刚准备着手编码时,发现有两个问题:
1.如何控制只显示三个元素;2.recyclerview是连续滚动的,时间选择器是跳跃滚动的(按下方向键是直接跳到下个元素的,不是连续滚动的列表)
问题1还好说自己计算item大小强制设置recyclerview高度,问题2仿佛有点麻烦,而且还要考虑分割线实现等问题
所以继续查找资料,看别人是如何自定义的,忽然看到有NumberPicker这个原生组件可以实现(我开发了这么些年竟然没用过这个组件,惭愧ing),那么似乎我只需要解决遥控器适配的问题
定义一个xml,三个NumberPicker横放用以分别显示年月日
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:background="@drawable/bg_shape_50ffffff"
android:paddingHorizontal="@dimen/px5"
android:orientation="horizontal">
<NumberPicker
android:id="@+id/np_year"
android:layout_width="0dp"
android:layout_weight="1"
android:background="@color/transparent"
android:layout_height="match_parent" />
<NumberPicker
android:id="@+id/np_month"
android:layout_width="0dp"
android:layout_weight="1"
android:background="@color/transparent"
android:layout_height="match_parent" />
<NumberPicker
android:id="@+id/np_day"
android:layout_width="0dp"
android:layout_weight="1"
android:background="@color/transparent"
android:layout_height="match_parent" />
</LinearLayout>
我使用的viewbinding加载xml,一步步实现发现如下问题
1.popwindow弹出后没有自动获取焦点,意味着遥控器不能点击popwindow上的任何内容
解决:在构造方法时设置focusable属性(我用的kotlin,所以在init代码块实现)
2.popwindow一直有一个黑色背景,我在对应的xml里写了背景不生效
解决:同样在构造方法里设置透明背景图 setBackgroundDrawable(ColorDrawable(Color.parseColor("#00000000"))),为什么设置了透明呢
因为我的设计图是要最终实现圆角背景的,发现设置其它颜色后再在xml设置圆角背景始终会有popwindow的背景所以设置成透明
3.选择年月后,每月天数不一样所以需要变化(比如我选中3月31日后,将月份切换到2,这时候肯定不能在选中31日这天)
解决:逻辑问题不详细讨论,具体看代码实现(关键地方都有备注)。遇到的另一个问题,切换了numpicker数据源后发现不会自动刷新,尝试invalidate等方法也不行,
后面发现重设了数据源后,再设置一遍maxValue就行了(这算bug?)
4.阻止numpicker可编辑
解决:这个网上有答案,descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
5.然后就是选中日期后如何监听用户点击遥控器上的确定按键完成日期选择
解决:设置numpicker可以获取焦点
binding?.npAmPm?.isFocusable = true
binding?.npAmPm?.isFocusableInTouchMode = true
binding?.npAmPm?.setOnClickListener {}
具体实现代码和布局在下面
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.view.LayoutInflater
import android.widget.LinearLayout
import android.widget.NumberPicker
import android.widget.PopupWindow
import java.lang.reflect.Field
class DatePickPopwin(val context: Context, val timemillis: Long = System.currentTimeMillis()): PopupWindow(context) {
private val logger = Logger.get(this)
private var binding: PopDatePickBinding? = null
private val yearDatas = mutableListOf<String>()
private val YEAR_START = 2000
private val monthDatas = (1..12).toMutableList().map { it.toString() }
private val dayDatas = mutableListOf<String>()
interface DatetimeListener{
fun onDateSelected(year: Int, month: Int, day: Int)
}
var dateListener: DatetimeListener? = null
init {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
binding = ViewBindingUtility.createViewBinding(inflater, PopDatePickBinding::class.java)
// contentView = inflater.inflate(R.layout.pop_time_pick, null)
contentView = binding?.root
isFocusable = true//获取焦点,没有这一步弹出对话框后,popwindow不能正常获取焦点
setBackgroundDrawable(ColorDrawable(Color.parseColor("#00000000")))//设置透明背景色,不然xml布局里的背景始终会有黑色
initDatas()
}
constructor(context: Context, width: Int, height: Int) : this(context){
contentView.layoutParams = LinearLayout.LayoutParams(width, height)
// setWidth(width) //设置宽高上面代码可能没用,setWidth setHeight实证是可用的
// setHeight(height)
}
private fun initDatas(){
initTimeYear()
initTimeMonth()
initTimeDay()
}
private fun initTimeYear() {
var count = 0
while (count++ < 100){
yearDatas.add((YEAR_START + count).toString())
}
binding?.npYear?.displayedValues = yearDatas.toTypedArray()
//是否循环显示
binding?.npYear?.wrapSelectorWheel = false
binding?.npYear?.minValue = 1
binding?.npYear?.maxValue = yearDatas.size
binding?.npYear?.value = yearDatas.indexOf(TimeConvertUtil.getYear(timemillis).toString()) + 1
//设置不可编辑
binding?.npYear?.descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
binding?.npYear?.setOnValueChangedListener { picker, oldVal, newVal ->
refreshDayByTimeChange()
}
setNumberPickerDividerColor(binding?.npYear!!, Color.TRANSPARENT)
binding?.npYear?.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus){
setNumberPickerDividerColor(binding?.npYear!!, Color.parseColor("#ffffff"))
}else{
setNumberPickerDividerColor(binding?.npYear!!, Color.TRANSPARENT)
}
}
//点击事件
binding?.npYear?.isFocusable = true
binding?.npYear?.isFocusableInTouchMode = true
binding?.npYear?.setOnClickListener {
doOnClick()
}
}
private fun initTimeMonth() {
binding?.npMonth?.displayedValues = monthDatas.toTypedArray()
binding?.npMonth?.minValue = 1
binding?.npMonth?.maxValue = monthDatas.size
binding?.npMonth?.value = TimeConvertUtil.getMonth(timemillis) + 1
//是否循环显示
binding?.npMonth?.wrapSelectorWheel = false
//设置不可编辑
binding?.npMonth?.descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
binding?.npMonth?.setOnValueChangedListener { picker, oldVal, newVal ->
refreshDayByTimeChange()
}
setNumberPickerDividerColor(binding?.npMonth!!, Color.TRANSPARENT)
binding?.npMonth?.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus){
setNumberPickerDividerColor(binding?.npMonth!!, Color.parseColor("#ffffff"))
}else{
setNumberPickerDividerColor(binding?.npMonth!!, Color.TRANSPARENT)
}
}
//点击事件
binding?.npMonth?.isFocusable = true
binding?.npMonth?.isFocusableInTouchMode = true
binding?.npMonth?.setOnClickListener {
doOnClick()
}
}
private fun initTimeDay() {
for (item in 1..TimeConvertUtil.getDaysInMonth(TimeConvertUtil.getYear(timemillis), TimeConvertUtil.getMonth(timemillis))){
dayDatas.add(item.toString())
}
binding?.npDay?.displayedValues = dayDatas.toTypedArray()
binding?.npDay?.minValue = 1
binding?.npDay?.maxValue = dayDatas.size
binding?.npDay?.value = TimeConvertUtil.getSomedayInMon(timemillis)
//是否循环显示
binding?.npDay?.wrapSelectorWheel = false
//设置不可编辑
binding?.npDay?.descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
binding?.npDay?.setOnValueChangedListener { picker, oldVal, newVal ->
}
setNumberPickerDividerColor(binding?.npDay!!, Color.TRANSPARENT)
binding?.npDay?.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus){
setNumberPickerDividerColor(binding?.npDay!!, Color.parseColor("#ffffff"))
}else{
setNumberPickerDividerColor(binding?.npDay!!, Color.TRANSPARENT)
}
}
//点击事件
binding?.npDay?.isFocusable = true
binding?.npDay?.isFocusableInTouchMode = true
binding?.npDay?.setOnClickListener {
doOnClick()
}
}
private fun setNumberPickerDividerColor(numberPicker: NumberPicker, color: Int) {
val pickerFields: Array<Field> = NumberPicker::class.java.declaredFields
for (pf in pickerFields) {
if (pf.getName().equals("mSelectionDivider")) {
pf.isAccessible = true
try {
//设置分割线的颜色值
pf.set(numberPicker, ColorDrawable(color))
} catch (e: IllegalAccessException) {
e.printStackTrace()
}
break
}
}
}
/**
* 解决切换年或月份时,不同月份天数不一致需要同步设置天的当前状态
*/
private fun refreshDayByTimeChange(){
val selDay = dayDatas.get(binding?.npDay?.value!! - 1)
logger.i("selDay is $selDay")
val selYear = yearDatas.get(binding?.npYear?.value!! - 1)
logger.i("selYear is $selYear")
val selMon = monthDatas.get(binding?.npMonth?.value!! - 1)
logger.i("selMon is $selMon")
val daysInMonth = TimeConvertUtil.getDaysInMonth(selYear.toInt(), selMon.toInt() - 1)
logger.i("daysInMonth is $daysInMonth")
val compareRes = daysInMonth - selDay.toInt()
dayDatas.clear()
for (item in 1..daysInMonth){
dayDatas.add(item.toString())
}
binding?.npDay?.value = 1 //此步是为了执行下一步时防止数组越界,系统bug?
binding?.npDay?.displayedValues = dayDatas.toTypedArray()
binding?.npDay?.maxValue = dayDatas.size//必须此步,否则上一步中更新数据源,在页面上不生效
//当前选中的天数在切换年或月时需要根据当月实际天数进行转换
if (compareRes > 0) {
binding?.npDay?.value = selDay.toInt()
}else if (compareRes < 0){
binding?.npDay?.value = dayDatas.size
}
}
/**
* 统一处理遥控器确认点击
*/
private fun doOnClick(){
val selDay = dayDatas.get(binding?.npDay?.value!! - 1)
logger.i("doOnClick selDay is $selDay")
val selYear = yearDatas.get(binding?.npYear?.value!! - 1)
logger.i("doOnClick selYear is $selYear")
val selMon = monthDatas.get(binding?.npMonth?.value!! - 1)
logger.i("doOnClick selMon is $selMon")
val daysInMonth = TimeConvertUtil.getDaysInMonth(selYear.toInt(), selMon.toInt() - 1)
logger.i("doOnClick daysInMonth is $daysInMonth")
dateListener?.onDateSelected(selYear.toInt(), selMon.toInt(), selDay.toInt())
dismiss()
}
}
最后放上时间选择器的实现吧
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:background="@drawable/bg_shape_50ffffff"
android:paddingHorizontal="@dimen/px5"
android:orientation="horizontal">
<NumberPicker
android:id="@+id/np_am_pm"
android:layout_width="0dp"
android:layout_weight="1"
android:background="@color/transparent"
android:layout_height="match_parent" />
<NumberPicker
android:id="@+id/np_hour"
android:layout_width="0dp"
android:layout_weight="1"
android:background="@color/transparent"
android:layout_height="match_parent" />
<NumberPicker
android:id="@+id/np_minute"
android:layout_width="0dp"
android:layout_weight="1"
android:background="@color/transparent"
android:layout_height="match_parent" />
</LinearLayout>
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.ShapeDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.NumberPicker
import android.widget.PopupWindow
import ch.qos.logback.core.util.Loader.getResources
import com.blankj.utilcode.util.TimeUtils
import com.blankj.utilcode.util.ToastUtils
import java.lang.reflect.Field
class TimePickPopwin(val context: Context, val timemillis: Long = System.currentTimeMillis()): PopupWindow(context) {
interface TimeListener{
fun onTimeSelected(hour: Int, min: Int)
}
var timeListener: TimeListener? = null
private var binding: PopTimePickBinding? = null
private val ampmDatas = mutableListOf<String>()
private val hourDatas = (0..23).map { it.toString() }
private val minuteDatas = (0..59).map { it.toString() }
init {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
binding = ViewBindingUtility.createViewBinding(inflater, PopTimePickBinding::class.java)
// contentView = inflater.inflate(R.layout.pop_time_pick, null)
contentView = binding?.root
isFocusable = true
setBackgroundDrawable(ColorDrawable(Color.parseColor("#00000000")))
initDatas()
}
constructor(context: Context, width: Int, height: Int, time: Long= System.currentTimeMillis()) : this(context, time){
contentView.layoutParams = LinearLayout.LayoutParams(width, height)
}
private fun initDatas(){
initTimeAmPm()
initTimeHour()
initTimeMinute()
}
private fun initTimeAmPm() {
ampmDatas.apply {
add(context.getString(R.string.time_am))
add(context.getString(R.string.time_pm))
}
binding?.npAmPm?.displayedValues = ampmDatas.toTypedArray()
if (TimeUtils.isAm()){
binding?.npAmPm?.value = 0
}else{
binding?.npAmPm?.value = 1
}
//是否循环显示
binding?.npAmPm?.wrapSelectorWheel = false
binding?.npAmPm?.minValue = 1
binding?.npAmPm?.maxValue = ampmDatas.size
//设置不可编辑 阻止子视图成为焦点视图,即使子视图调用requestFocus()也不能成为焦点视图
binding?.npAmPm?.descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
binding?.npAmPm?.isFocusable = true
binding?.npAmPm?.isFocusableInTouchMode = true
binding?.npAmPm?.value = if (TimeUtils.isAm(timemillis)) 1 else 2
binding?.npAmPm?.setOnClickListener {
doOnClick()
}
binding?.npAmPm?.setOnValueChangedListener { picker, oldVal, newVal ->
refreshTimeOnAmPmChange()
}
setNumberPickerDividerColor(binding?.npAmPm!!, Color.TRANSPARENT)
binding?.npAmPm?.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus){
setNumberPickerDividerColor(binding?.npAmPm!!, Color.parseColor("#ffffff"))
}else{
setNumberPickerDividerColor(binding?.npAmPm!!, Color.TRANSPARENT)
}
}
}
private fun initTimeHour() {
binding?.npHour?.displayedValues = hourDatas.toTypedArray()
binding?.npHour?.minValue = 1
binding?.npHour?.maxValue = hourDatas.size
//是否循环显示
binding?.npHour?.wrapSelectorWheel = false
//设置不可编辑
binding?.npHour?.descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
binding?.npHour?.isFocusable = true
binding?.npHour?.isFocusableInTouchMode = true
binding?.npHour?.value = TimeConvertUtil.getHour(timemillis) + 1
binding?.npHour?.setOnClickListener {
doOnClick()
}
binding?.npHour?.setOnValueChangedListener { picker, oldVal, newVal ->
refreshTimeOnHourChange()
}
setNumberPickerDividerColor(binding?.npHour!!, Color.TRANSPARENT)
binding?.npHour?.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus){
setNumberPickerDividerColor(binding?.npHour!!, Color.parseColor("#ffffff"))
}else{
setNumberPickerDividerColor(binding?.npHour!!, Color.TRANSPARENT)
}
}
}
private fun initTimeMinute() {
binding?.npMinute?.displayedValues = minuteDatas.toTypedArray()
binding?.npMinute?.minValue = 1
binding?.npMinute?.maxValue = minuteDatas.size
//是否循环显示
binding?.npMinute?.wrapSelectorWheel = false
//设置不可编辑
binding?.npMinute?.descendantFocusability = NumberPicker.FOCUS_BLOCK_DESCENDANTS
binding?.npMinute?.isFocusable = true
binding?.npMinute?.isFocusableInTouchMode = true
binding?.npMinute?.value = TimeConvertUtil.getMinute(timemillis)
binding?.npMinute?.setOnClickListener {
doOnClick()
}
binding?.npMinute?.setOnValueChangedListener { picker, oldVal, newVal ->
}
setNumberPickerDividerColor(binding?.npMinute!!, Color.TRANSPARENT)
binding?.npMinute?.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus){
setNumberPickerDividerColor(binding?.npMinute!!, Color.parseColor("#ffffff"))
}else{
setNumberPickerDividerColor(binding?.npMinute!!, Color.TRANSPARENT)
}
}
}
private fun setNumberPickerDividerColor(numberPicker: NumberPicker, color: Int) {
val pickerFields: Array<Field> = NumberPicker::class.java.declaredFields
for (pf in pickerFields) {
if (pf.getName().equals("mSelectionDivider")) {
pf.isAccessible = true
try {
//设置分割线的颜色值
pf.set(numberPicker, ColorDrawable(color))
} catch (e: IllegalAccessException) {
e.printStackTrace()
}
break
}
}
}
/**
* 根据上午下午的选择自动切换小时的显示
*/
private fun refreshTimeOnAmPmChange(){
val selAmPm = binding?.npAmPm?.value
val selHour = binding?.npHour?.value!! - 1
if (selAmPm == 1){//上午
if (selHour >= 12){
binding?.npHour?.value = 12
}
}else{
if (selHour < 12){
binding?.npHour?.value = 13
}
}
binding?.npHour?.maxValue = hourDatas.size
}
/**
* 根据小时的选择自动切换上午下午
*/
private fun refreshTimeOnHourChange(){
val selAmPm = binding?.npAmPm?.value
val selHour = binding?.npHour?.value!! - 1
if (selHour >= 12){//上午
binding?.npAmPm?.value = 2
}else{
binding?.npAmPm?.value = 1
}
binding?.npAmPm?.maxValue = ampmDatas.size
}
private fun doOnClick(){
val selHout = binding?.npHour?.value!! - 1
val selMinnute = binding?.npMinute?.value!! - 1
timeListener?.onTimeSelected(selHout, selMinnute)
dismiss()
}
override fun showAsDropDown(anchor: View?) {
super.showAsDropDown(anchor)
}
}
背景shape
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"
xmlns:tools="http://schemas.android.com/tools">
<solid android:color="#60708F" />
<corners android:radius="@dimen/px10" />
</shape>
最后来张效果图


本来想细说每一步遇到的问题的,写着写着发现好像也没啥可写的,总结就是方向对了,遇到问题查阅资料一步一步实现和尝试(比如更换数据源刷新问题),其实没有想象的那么困难
附上TimeConvertUtil类定义,方便初学者做日期转换
class TimeConvertUtil {
companion object{
val logger = Logger.get(this)
/**
* 获取{@param year}年{@param month}月总天数
*/
fun getDaysInMonth(year: Int, month: Int): Int {
val calendar: Calendar = Calendar.getInstance()
calendar.set(Calendar.YEAR, year)
calendar.set(Calendar.MONTH, month) // 设置月份,0表示一月份
val days: Int = calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
logger.i("getDaysInMonth is $days")
return days
}
/**
* 通过时间戳获取对应年份
*/
fun getYear(time: Long): Int {
val calendar: Calendar = Calendar.getInstance()
calendar.timeInMillis = time
return calendar.get(Calendar.YEAR)
}
/**
* 通过时间戳获取对应月份
*/
fun getMonth(time: Long): Int {
val calendar: Calendar = Calendar.getInstance()
calendar.timeInMillis = time
logger.i("getMonth ${calendar.get(Calendar.MONTH)}")
return calendar.get(Calendar.MONTH)
}
/**
* 通过时间戳获取对应天
*/
fun getSomedayInMon(time: Long): Int {
val calendar: Calendar = Calendar.getInstance()
calendar.timeInMillis = time
return calendar.get(Calendar.DAY_OF_MONTH)
}
/**
* 通过时间戳获取对应小时
*/
fun getHour(time: Long): Int {
val calendar: Calendar = Calendar.getInstance()
calendar.timeInMillis = time
return calendar.get(Calendar.HOUR_OF_DAY)
}
/**
* 通过时间戳获取对应分钟
*/
fun getMinute(time: Long): Int {
val calendar: Calendar = Calendar.getInstance()
calendar.timeInMillis = time
return calendar.get(Calendar.MINUTE)
}
}
}
tips:
在使用popwindow自带的showAsDropDown(View v)时,它会自动根据传入的v去调整宽度,如果你发现最后效果和你的v的宽度不一致,其实只需要将v自己的padding margin都移除(需要实现内外边距都放到v的子view去添加实现)
浙公网安备 33010602011771号