看了我上篇文章Android低代码开发 - 像启蒙和乐高玩具一样的MenuPanel 之后,本篇开始讲解代码。
源代码剖析
首先从MenuPanelItemRoot讲起。
kt
package dora.widget.panel
interface MenuPanelItemRoot {
/**
* 菜单的标题。
*
* @return
*/
var title: String?
fun hasTitle(): Boolean
/**
* 获取标题四周的间距。
*
* @return
*/
fun getTitleSpan(): Span
fun setTitleSpan(titleSpan: Span)
/**
* 菜单的上边距。
*
* @return
*/
var marginTop: Int
class Span {
var left = 0
var top = 0
var right = 0
var bottom = 0
constructor()
/**
* 根据水平间距和垂直间距设置四周的间距,常用。
*
* @param horizontal
* @param vertical
*/
constructor(horizontal: Int, vertical: Int) : this(
horizontal,
vertical,
horizontal,
vertical
)
constructor(left: Int, top: Int, right: Int, bottom: Int) {
this.left = left
this.top = top
this.right = right
this.bottom = bottom
}
}
}
无论是菜单还是菜单组,都要实现这个接口,这是什么模式啊?对,这是组合模式的应用。树枝节点可以添加若干树叶节点,且它们不会直接产生依赖,而是同时依赖其抽象。这个类里面看到,有title、title span和margin top,它们分别代表什么呢?
title就是红圈圈出来的地方。title span就是标题的间隙,你直接当成margins比较容易理解。
红框标出来的为margin top。如果有title的情况下,即title不为空以及空字符串,hasTitle()方法会检测出有标题。marginTop是指标题上面的区域。
接下来来看MenuPanel。
kt
package dora.widget.panel
import android.content.Context
import android.util.AttributeSet
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import java.util.LinkedList
import java.util.UUID
/**
* 通用功能菜单,类似于RecyclerView。
*/
open class MenuPanel : ScrollView, View.OnClickListener {
/**
* 面板的背景颜色,一般为浅灰色。
*/
private var panelBgColor = DEFAULT_PANEL_BG_COLOR
protected var menuPanelItems: MutableList<MenuPanelItem> = ArrayList()
protected var viewsCache: MutableList<View> = ArrayList()
private var onPanelMenuClickListener: OnPanelMenuClickListener? = null
private var onPanelScrollListener: OnPanelScrollListener? = null
private val groupInfoList: MutableList<GroupInfo> = ArrayList()
private val listenerInfo = LinkedList<ListenerDelegate>()
lateinit var panelRoot: FrameLayout
/**
* 存放Menu和Custom View。
*/
lateinit var container: LinearLayout
constructor(context: Context) : super(context) {
init(context)
}
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init(context)
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
init(context)
}
fun removeItem(item: MenuPanelItem): MenuPanel {
val position = seekForItemPosition(item)
if (position != SEEK_FOR_ITEM_ERROR_NOT_FOUND &&
position != SEEK_FOR_ITEM_ERROR_MISS_MENU_NAME
) {
removeItem(position)
} else {
Log.e(TAG, "failed to seekForItemPosition,$position")
}
return this
}
private fun init(context: Context) {
isFillViewport = true
addContainer(context)
}
fun setOnPanelMenuClickListener(l: OnPanelMenuClickListener) {
onPanelMenuClickListener = l
}
fun setOnPanelScrollListener(l: OnPanelScrollListener?) {
onPanelScrollListener = l
}
@JvmOverloads
fun parseItemView(item: MenuPanelItem?, isLoadData: Boolean = false): View {
val menuView = item!!.inflateView(context)
if (isLoadData) {
item.initData(menuView)
}
return menuView
}
val items: List<MenuPanelItem>
get() = menuPanelItems
fun getItem(position: Int): MenuPanelItem? {
if (position < 0 || position > menuPanelItems.size - 1) {
return null
}
return menuPanelItems[position]
}
val itemViewsCache: List<View>
get() = viewsCache
fun getGroupInfo(item: MenuPanelItem): GroupInfo? {
for (groupInfo in groupInfoList) {
if (groupInfo.hasItem(item)) {
return groupInfo
}
}
return null
}
/**
* 根据item的position移除一个item,此方法被多处引用,修改前需要理清布局层级结构。
*
* @param position
* @return
*/
fun removeItem(position: Int): MenuPanel {
val item = menuPanelItems[position]
val groupInfo = getGroupInfo(item)
val belongToGroup = groupInfo != null
val view = getCacheViewFromPosition(position)
if (!belongToGroup) {
container.removeView(view)
} else {
// 属于一个组
val menuGroupCard = groupInfo!!.groupMenuCard
menuGroupCard.removeView(view)
groupInfo.removeItem(item)
// 一个组内的item全部被移除后,也移除掉这个组
if (groupInfo.isEmpty) {
// 连同title一起移除
container.removeView(menuGroupCard)
groupInfoList.remove(groupInfo)
}
}
menuPanelItems.removeAt(position)
viewsCache.removeAt(position)
listenerInfo.removeAt(position)
return this
}
/**
* 清空所有item和相关view。
*/
fun clearAll(): MenuPanel {
if (menuPanelItems.size > 0) {
menuPanelItems.clear()
}
container.removeAllViews()
viewsCache.clear()
groupInfoList.clear()
listenerInfo.clear()
return this
}
/**
* 移除连续的item。
*
* @param start 第一个item的下标,包括
* @param end 最后一个item的下标,包括
* @return
*/
fun removeItemRange(start: Int, end: Int): MenuPanel {
for (i in start until end + 1) {
removeItem(start)
}
return this
}
/**
* 从某个位置移除到最后一个item。
*
* @param start 第一个item的下标,包括
* @return
*/
fun removeItemFrom(start: Int): MenuPanel {
val end = menuPanelItems.size - 1
if (start <= end) {
// 有就移除
removeItemRange(start, end)
}
return this
}
/**
* 从第一个item移除到某个位置。
*
* @param end 最后一个item的下标,包括
* @return
*/
fun removeItemTo(end: Int): MenuPanel {
val start = 0
removeItemRange(start, end)
return this
}
val itemCount: Int
get() = menuPanelItems.size
fun addMenuGroup(itemGroup: MenuPanelItemGroup): MenuPanel {
val hasTitle = itemGroup.hasTitle()
val items = itemGroup.items
val titleView = TextView(context)
titleView.setPadding(
itemGroup.getTitleSpan().left, itemGroup.getTitleSpan().top,
itemGroup.getTitleSpan().right, itemGroup.getTitleSpan().bottom
)
titleView.text = itemGroup.title
titleView.textSize = 15f
titleView.setTextColor(DEFAULT_TITLE_COLOR)
val menuGroupCard = LinearLayout(context)
menuGroupCard.orientation = LinearLayout.VERTICAL
val lp = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.topMargin = itemGroup.marginTop
menuGroupCard.layoutParams = lp
if (hasTitle) {
menuGroupCard.addView(titleView)
}
for (item in items) {
// 清除组内item的边距等
applyDefault(item)
addMenuToCard(item, menuGroupCard)
}
container.addView(menuGroupCard)
// 保存菜单组信息
groupInfoList.add(GroupInfo(items, menuGroupCard))
return this
}
override fun addView(child: View) {
if (child !is FrameLayout) {
return
}
if (childCount > 1) {
return
}
super.addView(child)
}
private fun addContainer(context: Context) {
panelRoot = FrameLayout(context)
container = LinearLayout(context)
container.orientation = LinearLayout.VERTICAL
container.setBackgroundColor(panelBgColor)
panelRoot.addView(container)
addView(panelRoot)
}
fun addMenu(item: MenuPanelItem): MenuPanel {
val menuView = bindItemListener(item)
if (!item.hasTitle()) {
container.addView(menuView)
} else {
val titleView = TextView(context)
titleView.setPadding(
item.getTitleSpan().left, item.getTitleSpan().top,
item.getTitleSpan().right, item.getTitleSpan().bottom
)
titleView.text = item.title
titleView.textSize = 15f
titleView.setTextColor(DEFAULT_PANEL_BG_COLOR)
val menuCard = LinearLayout(context)
menuCard.orientation = LinearLayout.VERTICAL
menuCard.addView(titleView)
menuCard.addView(menuView)
container.addView(menuCard)
}
return this
}
private fun addMenuToCard(item: MenuPanelItem, container: LinearLayout) {
val menuView = bindItemListener(item)
val lp = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.topMargin = item.marginTop
menuView.layoutParams = lp
container.addView(menuView)
}
fun seekForItemPosition(item: MenuPanelItem): Int {
for (i in menuPanelItems.indices) {
val mpi = menuPanelItems[i]
val menu = mpi.menuName
if (menu == "" || item.menuName == "") {
return SEEK_FOR_ITEM_ERROR_MISS_MENU_NAME //失去菜单名称
}
if (menu == item.menuName) {
return i
}
}
return SEEK_FOR_ITEM_ERROR_NOT_FOUND
}
/**
* 获取MenuPanel中条目布局中的子控件,推荐使用。
*
* @param position
* @param viewId
* @return
*/
fun getCacheChildView(position: Int, viewId: Int): View? {
val menuView = getCacheViewFromPosition(position)
return menuView?.findViewById(viewId)
}
/**
* 获取item的view,用于修改item的数据。
*
* @param item
* @return
*/
fun getCacheViewFromItem(item: MenuPanelItem): View? {
val position = seekForItemPosition(item)
return if (position != SEEK_FOR_ITEM_ERROR_NOT_FOUND &&
position != SEEK_FOR_ITEM_ERROR_MISS_MENU_NAME
) {
getCacheViewFromPosition(position)
} else null
}
/**
* 获取item的view,用于修改item的数据。
*
* @param position item的位置,从0开始
* @return
*/
fun getCacheViewFromPosition(position: Int): View? {
return if (position < viewsCache.size) {
viewsCache[position]
} else null
}
protected fun getCacheViewFromTag(tag: String): View? {
for (delegate in listenerInfo) {
val dtag = delegate.tag
if (dtag == tag) {
val position = delegate.position
return getCacheViewFromPosition(position)
}
}
return null
}
/**
* 绑定item的点击事件。
*
* @param item
* @return 绑定成功后返回item的view
*/
private fun bindItemListener(item: MenuPanelItem): View {
menuPanelItems.add(item)
//解析Item所对应的布局,并调用item的initData
val menuView = parseItemView(item, true)
viewsCache.add(menuView)
val tag = UUID.randomUUID().toString().substring(0, 16)
menuView.tag = tag
val delegate = getListenerInfo(tag)
menuView.setOnClickListener(delegate)
listenerInfo.add(delegate)
return menuView
}
private fun applyDefault(item: MenuPanelItem) {
// item的上边距修改为1dp
item.marginTop =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 1f,
resources.displayMetrics
).toInt()
// item去掉标题
item.title = ""
// item去掉标题边距
item.setTitleSpan(MenuPanelItemRoot.Span())
}
/**
* 不是菜单,所以不会影响菜单的点击事件位置,但需要自己处理控件内部的点击事件。
*
* @param view
* @param <T>
*/
fun <T : View> addCustomView(view: T): MenuPanel {
container.addView(view)
return this
}
fun <T : View> addCustomView(view: T, index: Int): MenuPanel {
container.addView(view, index)
return this
}
fun removeCustomViewAt(position: Int): MenuPanel {
if (container.childCount > position) {
// 有就移除
container.removeViewAt(position)
}
return this
}
/**
* 样式等参数改变才需要更新,只有类似于addItem、removeItem这样的,不需要调用此方法。
*/
open fun updatePanel() {
requestLayout()
}
fun getListenerInfo(tag: String): ListenerDelegate {
return ListenerDelegate(tag, menuPanelItems.size - 1, this)
}
class GroupInfo(
private var items: MutableList<MenuPanelItem>,
var groupMenuCard: LinearLayout
) {
fun hasItem(item: MenuPanelItem): Boolean {
return items.contains(item)
}
val itemCount: Int
get() = items.size
fun addItem(item: MenuPanelItem) {
items.add(item)
}
fun removeItem(item: MenuPanelItem?) {
items.remove(item)
}
val isEmpty: Boolean
get() = items.size == 0
fun getItems(): MutableList<MenuPanelItem> {
return items
}
}
override fun onClick(v: View) {
val tag = v.tag as String
for (delegate in listenerInfo) {
if (delegate.tag == tag) {
val clickPos = delegate.position
menuPanelItems[clickPos].menuName?.let {
onPanelMenuClickListener?.onMenuClick(clickPos, v, it)
}
break
}
}
}
fun setPanelBgColor(color: Int): MenuPanel {
panelBgColor = color
container.setBackgroundColor(panelBgColor)
return this
}
interface OnPanelMenuClickListener {
fun onMenuClick(position: Int, view: View, menuName: String)
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
if (scrollY == 0) {
onPanelScrollListener?.onScrollToTop()
} else if (panelRoot.measuredHeight == scrollY + height) {
onPanelScrollListener?.onScrollToBottom()
}
}
interface OnPanelScrollListener {
fun onScrollToTop()
fun onScrollToBottom()
}
class ListenerDelegate(
val tag: String,
val position: Int,
private val listener: OnClickListener
) : OnClickListener {
override fun onClick(v: View) {
listener.onClick(v)
}
}
companion object {
private const val TAG = "MenuPanel"
private const val DEFAULT_PANEL_BG_COLOR = -0xa0a07
private const val DEFAULT_TITLE_COLOR = -0x666667
private const val SEEK_FOR_ITEM_ERROR_NOT_FOUND = -1
private const val SEEK_FOR_ITEM_ERROR_MISS_MENU_NAME = -2
}
}
由于它仿RecyclerView的布局,它可以实现少量固定数量的item的高效创建,但不适应于大量item的场景。本来这个控件设计之初就是用在菜单上面的,而业务功能不可能无限多,所以这个问题可以忽略。根据代码我们可以得知,它是一个ScrollView,通常我们宽高都设置成match_parent,上面放一个titlebar,这样就填满了整个内容视图。这里面有addMenu()、addMenuGroup()和addCustomView()三种添加子控件的方法,只有前两种会受框架的约束。也就是说,如果你调用addCustomView()添加非菜单的视图,那么不会有OnPanelMenuClickListener
面板菜单点击事件的回调,需要自己处理自身的事件。通过getCacheChildView()
和getCacheViewFromPosition()
这两个方法都是用来更新菜单数据的,它们的区别在于前者是拿item的具体某一个子控件,后者是拿item本身。删除菜单和回调菜单的点击事件会使用到menuName
这个属性,所以你在addMenu()的时候,务必保证menuName不重复。无论是添加还是移除菜单,最后都需要调用updatePanel()
进行刷新。
kt
package dora.widget.panel
import android.content.Context
import android.util.AttributeSet
import android.util.TypedValue
import android.view.Gravity
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
/**
* 自动给最后加一行提示信息,如共有几条记录的菜单面板。
*/
class TipsMenuPanel : MenuPanel {
private var tips: String? = ""
private var tipsColor = -0x666667
private var tipsView: TextView? = null
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
)
fun setEmptyTips(): TipsMenuPanel {
setTips("")
return this
}
fun setTips(tips: String?): TipsMenuPanel {
this.tips = tips
return this
}
fun setTipsColor(color: Int): TipsMenuPanel {
tipsColor = color
return this
}
override fun updatePanel() {
if (tipsView != null) {
container.removeView(tipsView)
}
if (tips != null && tips!!.isNotEmpty()) {
tipsView = TextView(context)
val lp = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.topMargin = dp2px(context, 5f)
lp.bottomMargin = dp2px(context, 5f)
tipsView!!.gravity = Gravity.CENTER_HORIZONTAL
tipsView!!.setTextColor(tipsColor)
tipsView!!.layoutParams = lp
tipsView!!.text = tips
// 增加了底部的tips
container.addView(tipsView)
}
super.updatePanel()
}
private fun dp2px(context: Context, dpVal: Float): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dpVal, context.resources.displayMetrics
).toInt()
}
}
另外更新其子类TipsMenuPanel的底部提示信息的布局也需要调用updatePanel()方法。
开始使用
先给你们看一下dora-studio-plugin中是如何生成代码的,你就大概知道怎么使用了。
kt
/*
* Copyright (C) 2022 The Dora Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.dorachat.templates.recipes.app_package.res.layout
fun menuPanelActivityXml(
packageName: String,
activityClass: String
) = """
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="${packageName}.${activityClass}">
<data>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<dora.widget.DoraTitleBar
android:id="@+id/titleBar"
android:layout_width="match_parent"
android:layout_height="50dp"
app:dview_title="@string/app_name"
android:background="@color/colorPrimary"/>
<dora.widget.panel.MenuPanel
android:id="@+id/menuPanel"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</layout>
"""
以上为生成xml布局。
kt
/*
* Copyright (C) 2022 The Dora Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.dorachat.templates.recipes.app_package.src
fun menuPanelActivityKt(
applicationPackage: String,
packageName: String,
activityClass: String,
bindingName: String,
layoutName: String
) = """
package ${packageName}
import android.os.Bundle
import dora.BaseActivity
import ${applicationPackage}.R
import ${applicationPackage}.databinding.${bindingName}
class ${activityClass} : BaseActivity<${bindingName}>() {
override fun getLayoutId(): Int {
return R.layout.${layoutName}
}
override fun initData(savedInstanceState: Bundle?, binding: ${bindingName}) {
TODO("Not yet implemented")
}
}
"""
fun menuPanelActivity(
applicationPackage: String,
packageName: String,
activityClass: String,
bindingName: String,
layoutName: String
) = """
package ${packageName};
import android.os.Bundle;
import androidx.annotation.Nullable;
import dora.BaseActivity;
import ${applicationPackage}.R;
import ${applicationPackage}.databinding.${bindingName};
public class ${activityClass} extends BaseActivity<${bindingName}> {
@Override
protected int getLayoutId() {
return R.layout.${layoutName};
}
@Override
public void initData(@Nullable Bundle savedInstanceState, ${bindingName} binding) {
// TODO: Not yet implemented
// For Example:
// binding.menuPanel.addMenuGroup(
// MenuPanelItemGroup(
// DensityUtils.dp2px(10f),
// NormalMenuPanelItem("menuName", "text", true, "arrowText")
// )
// )
}
}
"""
以上为生成activity。
Gradle依赖配置
groovy
// 添加以下代码到项目根目录下的build.gradle
allprojects {
repositories {
maven { url "https://jitpack.io" }
}
}
// 添加以下代码到app模块的build.gradle
dependencies {
implementation 'com.github.dora4:dview-menu-panel:1.0'
}
添加菜单和菜单组
添加菜单
kt
binding.menuPanel.addMenu(NormalMenuPanelItem("menuName", "text", true, "arrowText"))
添加菜单组
kt
binding.menuPanel.addMenuGroup(
MenuPanelItemGroup(
DensityUtils.dp2px(10f),
NormalMenuPanelItem("menuName", "text", true, "arrowText")
)
)
不要无脑copy,参数请自行更换。
修改菜单数据
例如:更新颜色选择菜单的标签颜色。
kt
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK) {
if (requestCode == 0) {
data?.let {
val tagColor = it.getStringExtra(KEY_PICKED_COLOR)
tagColor?.let {
groupTitleColor = tagColor
}
val tvTag = mBinding.menuPanel.getCacheChildView(1, R.id.tv_menu_panel_color_picker_tag)
val color = Color.parseColor(tagColor)
val drawable = TagDrawable(
color, 0, 0,
DensityUtils.dp2px(this, 20f),
DensityUtils.dp2px(this, 10f),
)
if (tvTag != null) {
tvTag.background = drawable
}
}
}
}
}
设置菜单点击事件
kt
binding.menuPanel.setOnPanelMenuClickListener(object : MenuPanel.OnPanelMenuClickListener {
override fun onMenuClick(position: Int, view: View, menuName: String) {
when (menuName) {
"newMsgNotice" -> {
// 新消息通知
spmSelectContent("点击新消息通知")
val intent = Intent(this@SettingsActivity, NewMsgNoticeActivity::class.java)
startActivity(intent)
}
"switchLanguage" -> {
// 切换语言
spmSelectContent("点击切换语言")
val intent = Intent(this@SettingsActivity, SetLanguageActivity::class.java)
startActivity(intent)
}
"chatFont" -> {
IntentUtils.startActivityWithString(
this@SettingsActivity,
ChatFontActivity::class.java,
KEY_USER_ID,
userId
)
}
"chatBg" -> {
spmSelectContent("点击聊天背景")
IntentUtils.startActivityWithString(
this@SettingsActivity,
ChatBackgroundActivity::class.java,
KEY_USER_ID,
userId
)
}
"cacheClear" -> {
spmSelectContent("点击缓存清理")
IntentUtils.startActivityWithString(
this@SettingsActivity,
CacheCleanActivity::class.java,
KEY_USER_ID,
userId
)
}
"aboutUs" -> {
spmSelectContent("点击关于我们")
IntentUtils.startActivityWithString(
this@SettingsActivity,
AboutActivity::class.java,
KEY_USER_ID,
userId
)
}
"superUser" -> {
spmSelectContent("点击超级管理员")
IntentUtils.startActivityWithString(
this@SettingsActivity,
SuperUserActivity::class.java,
KEY_USER_ID,
userId
)
}
"logout" -> {
spmSelectContent("点击注销登录")
// 注销登录
dialog!!.show(
"logout",
getString(R.string.are_you_sure_logout)
)
}
}
}
})
这里注意一点,尽量使用menuName去判断具体是哪一个菜单,而不建议使用position。因为在有删除菜单的情况下,position会错位。spm埋点统计的代码你无需关心。
总结
本篇详细讲解了MenuPanel的核心代码实现及其使用方式,下篇我们演示IDE插件的操作流程。最后不忘点个star支持一下,https://github.com/dora4/dview-menu-panel 。