likes
comments
collection
share

Android自定义标题栏控件

作者站长头像
站长
· 阅读数 16

标题栏控件是在App开发中使用最为广泛的一类控件,使用频率在第一梯队。Google跟苹果都很重视这样的一个控件,所以Android就有了ActionBar,苹果就有了UINavigationController。那么既然Android官方定义了actionbar,为什么还要自己定义标题栏呢?我觉得原因有很多,其中最主要的原因就是使用不方便,坑多。当然actionbar也有它的好处,那就是功能强大。我写这个控件的目的并不能完全覆盖actionbar的所有功能,只是在这种类似这样界面的标题栏,写代码会很高效。

效果图

Android自定义标题栏控件

思路分析

我们分析一下实现这样一个通用的标题栏控件,需要注意什么?首先,这个标题栏分为3部分,左边是一个返回键按钮,中间是一个标题文本,右边是几个菜单按钮。我们可以通过自定义属性,配置标题、是否显示标题、标题的字体大小,以及标题文本是否加粗。返回键应该是要可以修改返回的图标、返回键的大小,以及距离左边边缘的距离。最最重要的来了,那就是返回键会经常被使用,所以对点击事件的交互体验有一定要求。要不然,点了半天,点不到,也返回不了界面,这样会让用户很生气,想砸手机。对于这个问题,我们的优化方案是,在按钮外面人为加个容器,强制增大点击事件的响应范围。这样就给用户手机很灵敏的错觉。最后是右边的功能菜单按钮,它们是水平排列的,从右向左下标依次是0、1、2。

如何使用

我们要有逆向思维,先思考别人调我们的API大概是怎样的代码,然后再开始封装。

// 添加以下代码到项目根目录下的build.gradle
allprojects {
    repositories {
        maven { url "https://jitpack.io" }
    }
}
// 添加以下代码到app模块的build.gradle
dependencies {
    implementation 'com.github.dora4:dview-titlebar:1.7'
}
val imageView = AppCompatImageView(this)
val dp24 = DensityUtils.dp2px(this, 24f)
imageView.layoutParams = RelativeLayout.LayoutParams(dp24, dp24)
imageView.setImageResource(R.drawable.ic_save)
val imageView2 = AppCompatImageView(this)
imageView2.layoutParams = RelativeLayout.LayoutParams(dp24, dp24)
imageView2.setImageResource(R.drawable.ic_confirm)
mBinding.titleBar
    .addMenuButton(imageView)
    .addMenuButton(imageView2)
    .setOnIconClickListener(object : DoraTitleBar.OnIconClickListener {
    override fun onIconBackClick(icon: AppCompatImageView) {
        LogUtils.i("返回")
    }

    override fun onIconMenuClick(position: Int, icon: AppCompatImageView) {
        LogUtils.i("点击了第${position}个菜单")
    }
})

Android自定义标题栏控件

代码实现

<resources>
    <declare-styleable name="DoraTitleBar">
        <attr name="dview_isShowBackIcon" format="boolean"/>
        <attr name="dview_backIcon" format="reference"/>
        <attr name="dview_backIconSize" format="dimension|reference"/>
        <!-- 用于给返回按钮增加点击区域 -->
        <attr name="dview_backIconBoxPadding" format="dimension|reference"/>
        <attr name="dview_backIconMarginStart" format="dimension|reference"/>
        <attr name="dview_isClickBackIconClose" format="boolean"/>
        <attr name="dview_isShowTitle" format="boolean"/>
        <attr name="dview_title" format="string|reference"/>
        <attr name="dview_titleTextColor" format="color|reference"/>
        <attr name="dview_titleTextSize" format="dimension|reference"/>
        <attr name="dview_isTitleTextBold" format="boolean"/>
        <!-- 用于给菜单按钮增加点击区域 -->
        <attr name="dview_menuIconBoxPadding" format="dimension|reference"/>
        <attr name="dview_menuIconMarginEnd" format="dimension|reference"/>
    </declare-styleable>
</resources>

以上就是这个自定义控件的所有自定义属性,下面我来分别解释。

dview_isShowBackIcon:是否显示左边的返回按钮,默认true

dview_backIcon:自定义返回按钮的图标

dview_backIconSize:返回按钮的尺寸

dview_backIconBoxPadding:给返回按钮添加的点击区域的大小

dview_backIconMarginStart:返回按钮距屏幕左边缘的距离

dview_isClickBackIconClose:点击返回按钮是否关闭当前activity

dview_isShowTitle:是否显示标题

dview_title:标题文本

dview_titleTextColor:标题字体颜色

dview_titleTextSize:标题字体大小

dview_isTitleTextBold:标题文本是否加粗

dview_menuIconBoxPadding:给菜单按钮添加的点击区域的大小

dview_menuIconMarginEnd:第0个菜单按钮距屏幕右边缘的距离

老生常谈了,首先肯定是解析自定义属性。

val a = context.obtainStyledAttributes(attrs, R.styleable.DoraTitleBar)
isShowBackIcon = a.getBoolean(R.styleable.DoraTitleBar_dview_isShowBackIcon, isShowBackIcon)
backIcon = a.getDrawable(R.styleable.DoraTitleBar_dview_backIcon) ?: backIcon
backIconSize = a.getDimensionPixelSize(R.styleable.DoraTitleBar_dview_backIconSize, backIconSize)
backIconMarginStart = a.getDimensionPixelSize(R.styleable.DoraTitleBar_dview_backIconMarginStart, backIconMarginStart)
menuIconMarginEnd = a.getDimensionPixelSize(R.styleable.DoraTitleBar_dview_menuIconMarginEnd, menuIconMarginEnd)
backIconBoxPadding = a.getDimensionPixelOffset(R.styleable.DoraTitleBar_dview_backIconBoxPadding, backIconBoxPadding)
menuIconBoxPadding = a.getDimensionPixelOffset(R.styleable.DoraTitleBar_dview_menuIconBoxPadding, menuIconBoxPadding)
isClickBackIconClose = a.getBoolean(R.styleable.DoraTitleBar_dview_isClickBackIconClose, isClickBackIconClose)
isShowTitle = a.getBoolean(R.styleable.DoraTitleBar_dview_isShowTitle, isShowTitle)
title = a.getString(R.styleable.DoraTitleBar_dview_title) ?: title
titleTextColor = a.getColor(R.styleable.DoraTitleBar_dview_titleTextColor, titleTextColor)
titleTextSize = a.getDimensionPixelSize(R.styleable.DoraTitleBar_dview_titleTextSize, titleTextSize)
isTitleTextBold = a.getBoolean(R.styleable.DoraTitleBar_dview_isTitleTextBold, isTitleTextBold)
a.recycle()

忘了个事情,我们这个标题栏控件继承自何种控件?没错,我想你应该想到了,那就是RelativeLayout。我们会把这些控件都添加到相对布局中,这需要使用kotlin的代码进行动态布局。由于左侧的返回按钮和标题栏文字是事先摆放好的,所以我们直接先绘制上去。右侧的功能菜单按钮列表,我们采用代码动态添加的方式,因为我们并不知道总共有多少个,也可能没有。但据我观测,手机最多也就能放3~4个,平板会多点。

我们解析完自定义属性后,会对一些初始化的控件进行动态布局。

private fun initView(context: Context) {
    val titleLp = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    titleLp.addRule(CENTER_IN_PARENT)
    val backIconBoxLp = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
    backIconBoxLp.marginStart = backIconMarginStart
    backIconBoxLp.addRule(ALIGN_PARENT_START)
    backIconBoxLp.addRule(CENTER_VERTICAL)
    val menuIconContainerLp = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
    menuIconContainerLp.marginEnd = menuIconMarginEnd
    menuIconContainerLp.addRule(ALIGN_PARENT_END)
    menuIconContainerLp.addRule(CENTER_VERTICAL)
    backIconBox = wrapButton(true, backIconView)
    if (isShowBackIcon) addView(backIconBox, backIconBoxLp)
    if (isShowTitle) addView(titleView, titleLp)
    menuIconContainer.gravity = Gravity.CENTER_VERTICAL
    menuIconContainer.orientation = LinearLayoutCompat.HORIZONTAL
    addView(menuIconContainer, menuIconContainerLp)
    backIconView.background = backIcon
    titleView.text = title
    titleView.textSize = px2sp(context, titleTextSize.toFloat())
    titleView.setTextColor(titleTextColor)
}
private fun initListener(context: Context) {
    backIconBox.setOnClickListener {
        onIconClickListener?.onIconBackClick(backIconView)
        if (isClickBackIconClose) {
            (context as Activity).finish()
        }
    }
}

如何将控件绘制到ViewGroup上呢?或者说将view绘制到ViewGroup的流程是怎样的?这是一个重点。首先要对这个布局容器的所有自绘子控件进行测量,我们使用measureChild()方法。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, applyWrapContentSize(heightMeasureSpec, dp2px(context, 48f)))
    if (isShowBackIcon) measureChild(backIconBox, widthMeasureSpec, heightMeasureSpec)
    if (isShowTitle) measureChild(titleView, widthMeasureSpec, heightMeasureSpec)
}

我们要考虑到有些开发者喜欢使用wrap_content作为标题栏的高度,没问题,我们给它个默认的高度,这样不至于显示不出控件。

/**
 * 设置wrap_content的情况下,给定默认宽高。
 *
 * @param expected 期望的值
 */
private fun applyWrapContentSize(measureSpec: Int, expected: Int): Int {
    var measureSpec = measureSpec
    val mode: Int = MeasureSpec.getMode(measureSpec)
    if (mode == MeasureSpec.UNSPECIFIED
        || mode == MeasureSpec.AT_MOST
    ) {
        measureSpec = MeasureSpec.makeMeasureSpec(expected, View.MeasureSpec.EXACTLY)
    }
    return measureSpec
}

很明显,使用者定义wrap_content为高度的时候,我们得到的测量规格MeasureSpec的值为MeasureSpec.AT_MOST。未指定高度,我们也给它个默认值。

然后接下来一步,就是将测量好的子控件,绘制到ViewGroup上。我们一般会重写onDraw()方法,我们使用dispatchDraw()方法其实更符合单一职责设计原则。dispatchDraw()方法会在绘制完本身xml中定义的子控件后,再添油加醋,哦不,再添加一些绘制。这些就是我们自己用代码绘制的view了。

override fun dispatchDraw(canvas: Canvas) {
    super.dispatchDraw(canvas)
    if (isShowBackIcon) drawChild(canvas, backIconBox, drawingTime)
    if (isShowTitle) drawChild(canvas, titleView, drawingTime)
}

这就是完整的在ViewGroup上绘制View的流程。

最后我们再看一下右侧的菜单按钮的实现。

fun addMenuButton(menuIconView: AppCompatImageView) : DoraTitleBar {
    val menuBox = wrapButton(false, menuIconView)
    menuBox.setOnClickListener {
        if (menuBox.childCount > 0) {
            val imageView = menuBox.getChildAt(0) as AppCompatImageView
            menuBoxList.forEachIndexed { index, frameLayout ->
                if (menuBox == frameLayout) {
                    onIconClickListener?.onIconMenuClick(index, imageView)
                }
            }
        }
    }
    // 添加到最前面去,这是因为索引从右边向左边递增
    menuIconContainer.addView(menuBox, 0)
    menuBoxList.add(menuBox)
    return this
}
/**
 * 增加按钮的点击区域范围。
 */
private fun wrapButton(isBackButton: Boolean, iconView: AppCompatImageView) : FrameLayout {
    val box = FrameLayout(context)
    if (isBackButton) {
        box.setPadding(backIconBoxPadding, backIconBoxPadding, backIconBoxPadding, backIconBoxPadding)
        val lp = FrameLayout.LayoutParams(backIconSize, backIconSize)
        box.addView(iconView, lp)
    } else {
        box.setPadding(menuIconBoxPadding, menuIconBoxPadding, menuIconBoxPadding, menuIconBoxPadding)
        box.addView(iconView)
    }
    return box
}

我们在添加进来的时候,直接设置点击事件,由于我们最多也就几个菜单按钮,所以使用循环去比对控件得到点击的下标,性能开销也不大。

最后,我们再添加一个点击事件,就大功告成了!

interface OnIconClickListener {

    /**
     * 左侧的返回键按钮被点击。
     */
    fun onIconBackClick(icon: AppCompatImageView)

    /**
     * 右侧的菜单按钮被点击。
     */
    fun onIconMenuClick(position: Int, icon: AppCompatImageView)
}

fun setOnIconClickListener(listener: OnIconClickListener) {
    onIconClickListener = listener
}

我们这里回调AppCompatImageView,而不回调增加点击范围的容器的原因也很简单,那就是最小知识设计原则,你用不到的就不提供给你。我想充其量最多也就换一下菜单按钮的颜色或图标的状态罢了。

开源库地址

github.com/dora4/dview…

示例代码地址

github.com/dora4/dora_…