标题栏控件是在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_…