一、动态表单介绍
一些Android项目中需要用到大量的表单提交,比如各种OA系统等,需要处理流程,主表显示,回报表填写,各种字段需要处理,需要进行动态的配置下发,对表单域进行解析处理,灵活配置,可以提高代码和项目的复用性。下面说说我的思路。
二、表单界面
列表 表单界面表单组件有灰色背景的分组标签和具体的表单域组成。
表单域由标签文字和具体需要填写和显示的数据组成。
三、代码目录结构
目录结构- 项目整体采用MVVM框架,使用databinding,主要使用kotlin和部分Java代码
- 依赖注入使用了Koin
- 数据库选择了Room
- 网络请求使用了retrofit
- 推送使用了极光推送
- 使用了阿里的路由,处理各种页面之间的跳转
- 部分事件处理使用了eventbus
- 图片加载使用了Gilde
- 缓存使用腾讯的mmkv开源框架,可以跨进程,性能优异。
- 地图和位置轨迹等使用了百度地图开放平台
四、动态表单部分
表单控件类型主要包括文本、单选、多选、下拉、手写签名、照片视频、位置、文件上传、分组控件等。主要思想就是面向对象的特点,封装、继承、多态和抽象。
1、FormWidget
主要处理一些通用的方法,比如取值,标签名称,view是否只读等
package com.sfy.gas.base
import android.widget.TextView
import com.sfy.gas.db.entities.form.FormTableDataFileDTO
import com.sfy.gas.db.entities.form.FormWidgetEntity
import com.sfy.gas.utils.FormAction
/**
* @Author : Cook
* @Date : 2022/2/8
* @Desc :
*/
open class FormWidget(open val formItem: FormWidgetEntity?, val formAction: FormAction) {
protected val TAG: String = this.javaClass.simpleName
/**
* 当前控件是否只读
*/
private var enable: Boolean = true
/**
* 获取当前控件的值
*/
open fun getValue(): String = ""
/**
* 设置控件的值 限文本
*/
open fun setValue(value: String) {}
/**
* 设置是否只读
*/
open fun setEnable(enable: Boolean) {
this.enable = enable
}
/**
* 获取控件只读状态
*/
open fun getEnable(): Boolean = enable
var fieldName: String? = ""
/**
* 获取标签名称
*/
open fun getLabelName(): String = "${formItem?.ITEMNAME}:"
/**
* 设置标签名称
*/
fun setLabelName(tv: TextView) {
tv.text = getLabelName()
}
}
2、BaseFormWidget
处理view加载,一些通用方法等
package com.sfy.gas.base
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import com.alibaba.android.arouter.launcher.ARouter
import com.blankj.utilcode.util.ThreadUtils
import com.kongzue.dialogx.dialogs.MessageDialog
import com.kongzue.dialogx.dialogs.TipDialog
import com.kongzue.dialogx.dialogs.WaitDialog
import com.sfy.gas.R
import com.sfy.gas.db.entities.form.FormTableDataFileDTO
import com.sfy.gas.db.entities.form.FormWidgetEntity
import com.sfy.gas.module.common.image.ImagePreviewActivity
import com.sfy.gas.utils.ARouterPath
import com.sfy.gas.utils.Constants
import com.sfy.gas.utils.FormAction
import com.sfy.gas.utils.ext.setLeftDrawable
/**
* @Author : Cook
* @Date : 2024/1/18
* @Desc :
*/
abstract class BaseFormWidget<T : ViewDataBinding>(
protected val context: AppCompatActivity,
formItem: FormWidgetEntity?, formAction: FormAction
) : FormWidget(formItem, formAction) {
var binding: T
fun <T : ViewDataBinding> binding(
@LayoutRes layoutId: Int
): T = DataBindingUtil.inflate(LayoutInflater.from(context), layoutId, null, false)
init {
binding = binding(getLayoutID())
initWidget()
}
/**
* 初始化控件
*/
private fun initWidget() {
getLabelTextView()?.text = getLabelName()
// setEnable(formItem?.ISREADONLY == 0)
if (formItem?.ISREQUIRED == 1) {
getLabelTextView()?.setLeftDrawable(R.drawable.icon_must)
}
}
/**
* 获取当前view
*/
fun getView(): View {
return binding.root
}
/**
* 当前控件是否可见
*/
fun isVisible(): Boolean {
return binding.root.visibility == View.VISIBLE
}
/**
* 获取标签的view
*/
open fun getLabelTextView(): TextView? = null
/**
* 获取布局layoutID
*/
abstract fun getLayoutID(): Int
/**
* 跳转 activity
*/
fun startActivity(title: String? = "--", routePath: String) {
ARouter.getInstance()
.build(routePath)
.withString(Constants.ACTIVITY_TITLE, title)
.navigation()
}
/**
* 弹窗
*/
fun showDialog(tips: String, action: () -> Unit) {
val builder = MessageDialog.build()
builder
.setTitle("提示")
.setMessage(tips)
.setCancelButton("取消") { _, _ ->
return@setCancelButton false
}
.setOkButton("确定") { dialog, _ ->
dialog.dismiss()
action()
return@setOkButton true
}
.show()
}
/**
* 加载
*/
fun showLoading(msg: String = "加载中...") {
ThreadUtils.runOnUiThread {
WaitDialog.show(msg)
}
}
/**
* 隐藏dialog
*/
fun dismissDialog() {
ThreadUtils.runOnUiThread {
WaitDialog.dismiss()
}
}
/**
* 显示toast
*/
fun showTips(msg: String) {
ThreadUtils.runOnUiThread {
TipDialog.show(msg, WaitDialog.TYPE.ERROR)
}
}
}
3、FormAction
定义钩子,从宿主activity获取需要的action
package com.sfy.gas.utils
import com.sfy.gas.db.entities.form.FormTableDataFileDTO
import com.sfy.gas.db.entities.form.FormWidgetEntity
import java.text.FieldPosition
/**
* @Author : Cook
* @Date : 2022/3/4
* @Desc :
*/
interface FormAction {
/**
*获取图片列表
*/
fun getImageList(
filedName: String,
formTableID: String,
result: (data: List<FormTableDataFileDTO>) -> Unit
)
/**
* 根据 filedName 获取字段值
*/
fun getWidgetValue(filedName: String): String
/**
* 获取此条表单的数据ID
*/
fun getUID(): String
/**
* 选取文件
*/
fun startChooseFile(formItem: FormWidgetEntity)
/**
* 删除文件
*/
fun deleteFile(position: Int)
}
4、FormWidgetEntity
表单域实体类,定义一些参数,类型等
package com.sfy.gas.db.entities.form
import java.util.*
/**
* @Author : Cook
* @Date : 2024/2/28
* @Desc :
*/
class FormWidgetEntity {
var ID = 0
var FORMTABLEID = 0
var IORDER = 0
var LISTORDER = 0
var VIEWORDER = 0
var ITEMNAME: String = ""
/**
* 字段名
*/
var FIELDNAME: String = ""
var ITEMTYPE: String = ""
var ITEMVALUE: String? = null
var DEFAULTVALUE: String = ""
var ISREQUIRED = 0
var ISDELETED: String? = null
var ISVIRTUAL = 0
var ISREADONLY = 0
var ISSYSTEM: Int = 0
}
5、实现栗子
EditTextFormWidget,输入文本框
package com.sfy.gas.view.widget
import android.text.InputType
import androidx.appcompat.app.AppCompatActivity
import com.sfy.gas.R
import com.sfy.gas.base.BaseFormWidget
import com.sfy.gas.databinding.FormTextBinding
import com.sfy.gas.db.entities.form.FormReportItemDTO
import com.sfy.gas.db.entities.form.FormWidgetEntity
import com.sfy.gas.utils.FormAction
import com.sfy.gas.utils.UIType
/**
* @Author : Cook
* @Date : 2024/1/18
* @Desc :
*/
class EditTextFormWidget(
context: AppCompatActivity,
formItem: FormWidgetEntity,
formAction: FormAction
) :
BaseFormWidget<FormTextBinding>(
context,
formItem, formAction
) {
init {
initEditType()
}
/**
* 获取当前值
*/
override fun getValue(): String {
return binding.formInput.text.toString()
}
/**
* 返回布局layout ID
*/
override fun getLayoutID() = R.layout.form_text
/**
* 设置值
*/
override fun setValue(value: String) {
binding.formInput.setText(value)
}
/**
* 设置是否只读
*/
override fun setEnable(enable: Boolean) {
binding.formInput.isEnabled = enable
}
/**
* 返回标签view
*/
override fun getLabelTextView() = binding.label
/**
* 初始化输入框类型 解析formItem字段
*/
private fun initEditType() {
formItem?.let {
when (UIType.valueOf(it.ITEMTYPE)) {
UIType.Numberic -> {
binding.formInput.inputType = InputType.TYPE_CLASS_NUMBER
}
else -> {}
}
}
}
}
6、GroupFormWidget
分组标签,分组显示使用。
package com.sfy.gas.view.widget
import androidx.appcompat.app.AppCompatActivity
import com.sfy.gas.R
import com.sfy.gas.base.BaseFormWidget
import com.sfy.gas.databinding.FormGroupBinding
import com.sfy.gas.utils.FormAction
/**
* @Author : Cook
* @Date : 2024/2/8
* @Desc :
*/
class GroupFormWidget(context: AppCompatActivity, formAction: FormAction) :
BaseFormWidget<FormGroupBinding>(context, null, formAction) {
override fun getLayoutID() = R.layout.form_group
override fun setValue(value: String) {
binding.text.text = value.ifEmpty { "基本信息" }
}
}
7、DynamicFormHelper
工具类,根据数据类型返回对应的Wdiget
package com.sfy.gas.utils
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import com.sfy.gas.base.BaseFormWidget
import com.sfy.gas.db.entities.form.FormWidgetEntity
import com.sfy.gas.view.widget.*
/**
* @Author : Cook
* @Date : 2024/2/8
* @Desc :
*/
object DynamicFormHelper {
fun getWidget(
context: AppCompatActivity,
formReportItem: FormWidgetEntity, action: FormAction
): BaseFormWidget<*> {
Log.i("getWidget", "itemType=${formReportItem.ITEMTYPE}")
return when (UIType.valueOf(formReportItem.ITEMTYPE)) {
UIType.HSingleLineText -> {
EditTextFormWidget(context, formReportItem, action)
}
UIType.HTime, UIType.HDate, UIType.HDateTime, UIType.DateTime, UIType.Date, UIType.Time -> {
DateTimeWidget(context, formReportItem, action)
}
UIType.HRadioBox, UIType.RadioBox -> {
RadioBoxWidget(context, formReportItem, action)
}
UIType.HList, UIType.HDropList, UIType.DropList -> {
SpinnerFormWidget(context, formReportItem, action)
}
UIType.Image, UIType.HImage, UIType.HVideo, UIType.Video -> {
MediaFormWidget(context, formReportItem, action)
}
UIType.TextArea, UIType.HTextArea -> {
TextAreaFormWidget(context, formReportItem, action)
}
UIType.ChooseButton -> {
FormListWidget(context, formReportItem, action)
}
UIType.SubForm, UIType.HSubForm -> {
SubFormWidget(context, formReportItem, action)
}
UIType.HDrawing, UIType.Drawing -> {
HandWriteWidget(context, formReportItem, action)
}
UIType.Audio, UIType.HAudio -> {
AudioFormWidget(context, formReportItem, action)
}
UIType.File, UIType.HFile -> {
FileWidget(context, formReportItem, action)
}
else -> {
EditTextFormWidget(context, formReportItem, action)
}
}
}
}
8、宿主Activity的处理
1、主要加载流程
- 从本地数据库或者服务器获取表单数据
- 解析并加载表单数据
- 先根据FormItem,通过上面的工具类DynamicFormHelper获取表单域view。
- 根据分组信息,把表单域view存放到map里面。
- 通过容器layout,加载表单域view。
- 处理加载一些有默认值的情况,比如是否隐藏,是否可用等
/**
* 表单域的原始数据列表
*/
private var formTableItemList: List<FormTableItemDTO> = arrayListOf()
/**
* 根据分组存放表单数据 key是groupName 分组名称
*/
private val widgetGroupMap = HashMap<String, ArrayList<BaseFormWidget<*>>>()
/**
* 存放表单域的map key是fieldName
*/
private val widgetMap = HashMap<String, BaseFormWidget<*>>()
private fun parseFormList() {
CoroutineScope(Dispatchers.Main).launch {
for (data in formTableItemList) {
val widgetData = data.toWidgetEntity()
val widget = DynamicFormHelper.getWidget(
this@DynamicMainFormActivity,
widgetData,
this@DynamicMainFormActivity
)
widgetMap[widgetData.FIELDNAME] = widget
val groupName = widgetData.GROUPNAME ?: ""
var listWidget = widgetGroupMap[groupName]
if (listWidget == null) {
sortList.add(groupName)
listWidget = arrayListOf()
}
listWidget.add(widget)
widgetGroupMap[groupName] = listWidget
val type = UIType.valueOf(widgetData.ITEMTYPE)
if (type == UIType.Audio || type == UIType.HAudio) {
needAudio = true
audioField = widgetData.FIELDNAME
}
}
for (fieldName in sortList) {
val value = widgetGroupMap[fieldName]
if (value.isNullOrEmpty()) {
continue
}
val groupView = GroupFormWidget(
this@DynamicMainFormActivity,
this@DynamicMainFormActivity
).apply {
setValue(fieldName)
}.getView()
binding.contentLayout.addView(groupView)
var count = 0
for (data in value) {
val widgetView = data.getView()
binding.contentLayout.addView(data.getView())
val key = data.formItem?.FIELDNAME ?: ""
val mapDataValue = dataMap[key]?.toString() ?: ""
data.formItem?.let {
val dataValue = mapDataValue.ifEmpty {
when (it.DEFAULTVALUETYPE) {
"1", "4" -> {
dataMap[key]?.toString() ?: dataMap[it.DEFAULTVALUE]?.toString()
?: ""
}
"0" -> {
data.formItem?.DEFAULTVALUE ?: ""
}
"6" -> {
withContext(Dispatchers.IO) {
DBOptionUtils.genAutoNumber(
data.formItem?.DEFAULTVALUE,
"$tableid",
key
).Second
}
}
else -> {
GeneralParams.getValue(it.DEFAULTVALUE)
}
}
}
data.setValue(dataValue)
if (it.ISSYSTEM == 1 || it.ISHIDDEN == 1) {
widgetView.isGone = true
count++
}
}
if (isNew == true) {
data.setEnable(data.formItem?.ISREADONLY == 0)
} else {
data.setEnable(false)
}
}
if (count == value.size) {
groupView.isGone = true
}
}
if (needAudio) {
RecordManager.getInstance().setRecordResultListener {
Log.e(TAG, "${it.path}")
val createDate = AppUtils.getOracleDateTimeNowSSS()
val dataFileDTO = FormTableDataFileDTO().apply {
ID = AppUtils.getUUID()
FORMTABLEID = "$tableid"
FIELDNAME = audioField
FILENAME = FileUtils.getFileName(it.path)
FILEPATH = it.path
CREATER = CacheHelper.accountID
CREATEDATE = createDate
FILETYPE = Constants.AUDIO
FILESIZE = FileUtils.getLength(it.path).toDouble()
PROCESSED = "-1"
}
mediaMap[audioField] = arrayListOf(dataFileDTO)
}
RecordManager.getInstance().start()
}
}
}
2、表单数据校验
对于用户输入的数据需要做一个合法性校验,有的可以在输入的时候做限制,比如一个数字文本,输入的时候就可以限制只能输入数据,其他的就需要在提交的时候校验,比如必填项不能为空等
private fun check(): Tuple<Boolean, String> {
for (value in widgetMap) {
val widget = value.value
if (widget.isVisible()
.not() && widget.formItem?.ISHIDDEN != 1 && widget.formItem?.ISSYSTEM != 1
) {
continue
}
if (widget is MediaFormWidget || widget is AudioFormWidget || widget is HandWriteWidget || widget is FileWidget) {
if (widget.formItem?.ISREQUIRED == 1) {
if (mediaMap[widget.formItem?.FIELDNAME].isNullOrEmpty()) {
return Tuple(false, "【${widget.formItem?.ITEMNAME}】不能为空")
}
continue
}
continue
}
if (widget.formItem?.ISREQUIRED == 1) {
if (widget.getValue().trim().isEmpty()) {
return Tuple(false, "【${widget.formItem?.ITEMNAME}】不能为空")
}
}
}
return Tuple(true, "")
}
3、提交表单
主要就是获取用户填写的内容,整理并保存到本地或者直接上传云端
整理数据主要根据widgetMap来处理,遍历widgetMap获取对应的表单域,通过getValue抽象方法获取对应的值,组成map上传到服务端并存到本地。
suspend fun uploadMainTableData(
tableID: String, tableName: String,
widgetMap: HashMap<String, BaseFormWidget<*>>,
mediaMap: HashMap<String, ArrayList<FormTableDataFileDTO>>
): ResultResponse {
val id = AppUtils.getUUID()
val stateName = formTableWorkFlowDAO.queryInsertState(tableID)?.STATENAME ?: ""
val dataMap = HashMap<String, Any>().apply {
put("FORMTABLEID", tableID)
put("TABLENAME", tableName)
put("GUID", id)
put("STATE", stateName)
}
for ((key, value) in widgetMap) {
val isVisible = value.isVisible()
val formItem = value.formItem ?: FormWidgetEntity()
if (isVisible.not() && formItem.ISHIDDEN != 1 && formItem.ISSYSTEM != 1) {
continue
}
if (value is MediaFormWidget || value is HandWriteWidget || value is AudioFormWidget|| value is FileWidget) {
continue
}
if (formItem.DEFAULTVALUETYPE == "6") {
val code = CacheHelper.getAutoCode(tableID)
val sequenceDTO = formCategorySequenceDAO.queryCode(tableID, formItem.FIELDNAME)
sequenceDTO?.CODE = code
sequenceDTO?.let { formCategorySequenceDAO.update(it) }
}
dataMap[key] = value.getValue()
}
val waterText = AppUtils.getWaterText()
val textColor = ColorUtils.getColor(R.color.panda)
for ((key, value) in mediaMap) {
val mediaList = arrayListOf<MediaListParams>()
for (media in value) {
var path: String
media.FORMTABLEDATAID = id
media.FILEPATH?.let {
if (media.FILETYPE == Constants.PHOTO) {
val imageFile = File(it)
val bitmap = ImageUtils.getBitmap(imageFile)
val waterBitmap = ImageUtils.addTextWatermark(
bitmap,
waterText,
30,
textColor,
30F,
30F
)
ImageUtils.save(
waterBitmap,
imageFile.path,
Bitmap.CompressFormat.PNG,
true
)
val compressedImageFile = Compressor.compress(Utils.getApp(), imageFile)
media.FILEPATH = compressedImageFile.path
media.FILENAME = FileUtils.getFileName(compressedImageFile)
media.FILESIZE = FileUtils.getLength(compressedImageFile).toDouble()
path = compressedImageFile.path
} else {
path = it
}
val uploadPath = AppUtils.getUploadFilePath() + media.FILENAME
val result = uploadFileRepository.upload(path, uploadPath)
media.PROCESSED = if (result.code == SUCCESS_CODE) {
"1"
} else {
"-1"
}
mediaList.add(MediaListParams(uploadPath))
if (media.PROCESSED != "1") {
formTableDataFileDAO.insert(media)
}
}
}
dataMap[key] = mediaList
}
val map = HashMap<String, String>().apply {
put("command", "directlabel")
put("compress", "")
}
val params =
UploadMainTableParams(data = arrayListOf<HashMap<String, Any>>().apply { add(dataMap) })
val result = resultCall {
service.uploadMainTableData(map, params)
}
网友评论