# 插件视图(PluginView)

PluginView 是插件 UI 系统的基础,所有 UI 组件都实现了 PluginView 接口。本文档将介绍如何使用 Builder 模式构建插件视图,以及如何通过组合布局来创建复杂的用户界面。

# 快速开始

// 创建一个简单的垂直布局
PluginView view = pluginUI.buildVerticalLayout()
    .addTextView("title").text("欢迎使用")
    .addTextView("desc").text("这是一个简单的示例")
    .addButton("btn").text("点击我").onClick(v -> {
        pluginUI.showToast("按钮被点击了");
    })
    .build();

// 在对话框中显示
pluginUI.buildDialog().setView(view).show();

# 支持的视图类型

插件 UI 系统提供了常见的组件,通过 Builder 的 addXxx() 方法添加。

# 基础组件

组件 说明 Builder 方法
PluginView 普通视图(可用作分割线等) addView()
PluginTextView 文本视图,用于显示文本内容 addTextView()
PluginImageView 图片视图,用于显示图片和图标 addImageView()
PluginButton 按钮,支持多种风格 addButton()
PluginEditText 文本编辑框,支持语法高亮 addEditText() / addEditBox()
PluginSpinner 下拉选择框 addSpinner()
PluginProgressBar 进度条(水平/圆形) addProgressBar()

# 复合按钮

组件 说明 Builder 方法
PluginCheckBox 复选框,用于多选场景 addCheckBox()
PluginSwitchButton 开关按钮,用于开/关设置 addSwitchButton()
PluginRadioButton 单选按钮,配合单选组使用 addRadioButton()

# 布局容器

组件 说明 Builder 方法
PluginLinearLayout 线性布局(水平/垂直) addHorizontalLayout() / addVerticalLayout()
PluginFrameLayout 帧布局,子视图叠加显示 addFrameLayout()
PluginRadioGroup 单选按钮组,管理单选逻辑 addRadioGroup()

提示:每个组件都有对应的 Builder 类(如 PluginTextViewBuilder),通过 Builder 可以链式设置组件的各种属性。详细的组件属性和用法请查看:

  • Demo 演示mt-plugin-v3-demo (opens new window) 中的 ExampleUI.java
  • API 源码注释:在 Android Studio 中通过查看依赖库源码的方式查看各接口的详细注释

# 布局思路

构建复杂视图的核心思路是:将界面拆分为多个水平布局和垂直布局的组合

# 线性布局

插件 UI 提供两种线性布局:

// 垂直布局:子视图从上往下排列
pluginUI.buildVerticalLayout()
    .addTextView().text("第一行")
    .addTextView().text("第二行")
    .addTextView().text("第三行")
    .build();

// 水平布局:子视图从左往右排列
pluginUI.buildHorizontalLayout()
    .addTextView().text("第一列")
    .addTextView().text("第二列")
    .addTextView().text("第三列")
    .build();

# 嵌套布局

使用 children() 方法可以在布局中嵌套子布局,从而构建复杂界面:

// 垂直布局中嵌套水平布局
pluginUI.buildVerticalLayout()
    .addHorizontalLayout().children(row -> row
        .addTextView().text("姓名")
        .addEditText().hint("请输入姓名")
    )
    .addHorizontalLayout().children(row -> row
        .addTextView().text("邮箱")
        .addEditText().hint("请输入邮箱")
    )
    .addButton().text("提交")
    .build();

# 子视图样式

使用 childrenStyle() 方法可以为布局容器内的所有子视图设置统一样式。样式会向下继承——如果没有设置自定义样式,子视图会继承上一级的样式,最顶级的样式来自 pluginUI.defaultStyle()

pluginUI.buildVerticalLayout()
    // 为这个水平布局的所有子视图设置统一样式
    .addHorizontalLayout().childrenStyle(new PluginUI.StyleWrapper() {
        @Override
        protected void handleTextView(PluginUI pluginUI, PluginTextViewBuilder builder) {
            super.handleTextView(pluginUI, builder);
            builder.width(0).layoutWeight(1)  // 均匀分配宽度
                   .textGravity(Gravity.CENTER)
                   .paddingDp(16)
                   .textColor(0xFF000000);
        }
    }).children(row -> row
        .addTextView().text("1-1").backgroundColor(0xFFFF5555)
        .addTextView().text("1-2").backgroundColor(0xFF55FF55)
        .addTextView().text("1-3").backgroundColor(0xFF5555FF)
    )
    .build();

提示childrenStyle()pluginUI.defaultStyle() 的区别:

  • defaultStyle() 设置整个 PluginUI 的默认样式,是最顶级的样式来源
  • childrenStyle() 只影响当前布局容器的所有子视图(包括嵌套的子视图)

# 布局示例

# 表单布局

表单是最常见的布局场景,通常由多行「标签 + 输入框」组成:

PluginView form = pluginUI.buildVerticalLayout()
    // 第一行:用户名
    .addHorizontalLayout().children(row -> row
        .addTextView("label1").text("用户名")
        .addEditText("input1").hint("请输入用户名")
    )
    // 第二行:密码
    .addHorizontalLayout().children(row -> row
        .addTextView("label2").text("密码")
        .addEditText("input2").hint("请输入密码")
    )
    // 第三行:确认密码
    .addHorizontalLayout().children(row -> row
        .addTextView("label3").text("确认密码")
        .addEditText("input3").hint("请再次输入密码")
    )
    .addButton().text("注册").widthMatchParent()
    .build();

// 统一标签宽度,让输入框左对齐
form.unifyWidth("label1", "label2", "label3");

# 网格布局

通过嵌套水平布局,可以实现网格效果:

pluginUI.buildVerticalLayout()
    .addHorizontalLayout().children(row -> row
        .addTextView().text("1-1").width(0).layoutWeight(1).backgroundColor(0xFFFF5555)
        .addTextView().text("1-2").width(0).layoutWeight(1).backgroundColor(0xFF55FF55)
        .addTextView().text("1-3").width(0).layoutWeight(1).backgroundColor(0xFF5555FF)
    )
    .addHorizontalLayout().children(row -> row
        .addTextView().text("2-1").width(0).layoutWeight(1).backgroundColor(0xFF55FF55)
        .addTextView().text("2-2").width(0).layoutWeight(1).backgroundColor(0xFF5555FF)
        .addTextView().text("2-3").width(0).layoutWeight(1).backgroundColor(0xFFFF5555)
    )
    .build();

提示:使用 width(0).layoutWeight(1) 可以让多个视图均匀分配宽度。权重值越大,分配的空间越多。

# 分割线

使用 addView() 可以添加分割线:

pluginUI.buildVerticalLayout()
    .addTextView().text("上方内容")
    // 分割线:高度1px,宽度撑满,使用分割线颜色
    .addView().height(1).widthMatchParent().backgroundColor(pluginUI.colorDivider())
    .addTextView().text("下方内容")
    .build();

# 视图 ID

视图 ID 用于在构建完成后查找和操作特定的视图。

# 设置 ID

在 Builder 中通过 id() 方法或直接在 addXxx() 中传入 ID:

// 方式一:在 addXxx() 中传入 ID
pluginUI.buildVerticalLayout()
    .addTextView("myText").text("Hello")
    .addButton("myButton").text("Click")
    .build();

// 方式二:使用 id() 方法
pluginUI.buildVerticalLayout()
    .addTextView().id("myText").text("Hello")
    .addButton().id("myButton").text("Click")
    .build();

# 查找视图

使用 findViewById()requireViewById() 查找视图:

PluginView view = pluginUI.buildVerticalLayout()
    .addTextView("text").text("Hello")
    .addButton("button").text("修改文本")
    .build();

// findViewById:找不到返回 null
PluginTextView textView = view.findViewById("text");
if (textView != null) {
    textView.setText("World");
}

// requireViewById:找不到抛出异常(确定存在时使用)
PluginButton button = view.requireViewById("button");
button.setOnClickListener(v -> {
    // ...
});

# ID 严格模式

默认情况下,ID 严格模式是开启的。在同一个根布局中,不允许出现重复的 ID:

// 默认开启严格模式,下面的代码会抛出异常
pluginUI.buildVerticalLayout()
    .addTextView("text").text("Text 1")
    .addTextView("text").text("Text 2")  // 抛出异常:ID 重复
    .build();

// 禁用严格模式后允许重复 ID
pluginUI.disableStrictIdMode()
    .buildVerticalLayout()
    .addTextView("text").text("Text 1")
    .addTextView("text").text("Text 2")  // 不会抛出异常
    .build();

# getRootView 缓存优化

getRootView() 返回视图树的根容器。根视图会缓存 findViewById()requireViewById() 的查找结果,因此在回调方法中通过根视图获取其他视图不会有性能问题。

这个特性使得全链式调用成为可能——在 Builder 的回调中直接通过 getRootView() 获取其他视图:

// 全链式调用:在 onClick 回调中通过 getRootView() 获取其他视图
pluginUI.buildVerticalLayout()
    .addEditText("input").hint("请输入内容")
    .addTextView("error").text("输入不能为空").textColor(Color.RED).gone()
    .addButton("submit").text("提交").onClick(v -> {
        // 通过根视图获取其他视图(结果会被缓存,性能无忧)
        PluginViewGroup root = v.getRootView();
        PluginEditText input = root.requireViewById("input");
        PluginTextView error = root.requireViewById("error");

        if (input.getText().toString().isEmpty()) {
            error.setVisible();
        } else {
            error.setGone();
            pluginUI.showToast("提交成功");
        }
    })
    .showDialog();

# 宽度统一

unifyWidth() 方法用于统一多个视图的宽度,以最宽的视图为准。

# 使用场景

在表单布局中,不同标签的文本长度不同,会导致后面的输入框无法对齐:

未使用 unifyWidth() 时:

[用户名]  [___________________]
[密码]  [___________________]
[电子邮箱]  [___________________]
 ↑ 标签宽度不一致,输入框起始位置不对齐

使用 unifyWidth() 后,所有标签会使用相同的宽度(以最宽的「电子邮箱」为准),输入框就能对齐了:

使用 unifyWidth() 后:

[用户名 ]  [___________________]
[密码  ]  [___________________]
[电子邮箱]  [___________________]
 ↑ 标签宽度统一,输入框起始位置对齐

# unifyWidth 方法

有两种调用方式:

// 方式一:传入 ID 数组
view.unifyWidth("label1", "label2", "label3");

// 方式二:传入 View 数组
PluginTextView label1 = view.requireViewById("label1");
PluginTextView label2 = view.requireViewById("label2");
PluginTextView label3 = view.requireViewById("label3");
view.unifyWidth(label1, label2, label3);

# 完整示例

PluginView view = pluginUI.buildVerticalLayout()
    .addHorizontalLayout().children(row -> row
        .addTextView("label1").text("用户名")
        .addEditText().hint("请输入用户名")
    )
    .addHorizontalLayout().children(row -> row
        .addTextView("label2").text("密码")
        .addEditText().hint("请输入密码")
    )
    .addHorizontalLayout().children(row -> row
        .addTextView("label3").text("电子邮箱")
        .addEditText().hint("请输入邮箱")
    )
    .build();

// 统一标签宽度
view.unifyWidth("label1", "label2", "label3");

# 注意事项

注意

  • 参与对齐的视图建议使用 WRAP_CONTENT 作为宽度参数,以获得最佳效果
  • 如果视图使用 MATCH_PARENT 或固定宽度,可能无法达到预期的对齐效果
  • 至少需要传入 2 个 ID 或视图才有意义

# 本地化文本支持

UI 组件直接支持 {key} 格式的本地化文本,无需手动调用 getString()

# 支持的方法

所有接受文本参数的方法都支持本地化文本,包括但不限于:

  • text() - 设置文本内容
  • hint() - 设置提示文本
  • 其他接受 CharSequence 参数的方法

# 使用示例

// 使用本地化文本
pluginUI.buildVerticalLayout()
    .addTextView().text("{plugin_name}")      // 从 strings 语言包获取
    .addButton().text("{ok}")                 // 使用 MT 内置词条
    .addEditText().hint("{input_hint}")       // 编辑框提示
    .build();

// 等价于手动调用 getString()
pluginUI.buildVerticalLayout()
    .addTextView().text(context.getString("plugin_name"))
    .addButton().text(context.getString("ok"))
    .addEditText().hint(context.getString("input_hint"))
    .build();

提示:关于本地化文本的详细说明,包括语言包配置、MT 内置词条等,请查看 本地化文本

# 常用属性设置

以下是 PluginView 接口提供的常用属性设置方法。

# 尺寸设置

// 尺寸常量
int MATCH_PARENT = -1;  // 匹配父容器尺寸
int WRAP_CONTENT = -2;  // 根据内容自适应尺寸

// 设置宽高(px)
view.setWidth(100);
view.setHeight(50);
view.setSize(100, 50);

// 设置宽高(dp)
view.setWidthDp(100);
view.setHeightDp(50);
view.setSizeDp(100, 50);

// Builder 链式调用
.addTextView().width(0).layoutWeight(1)  // 使用权重分配宽度
.addButton().widthMatchParent()          // 宽度撑满
.addView().height(1)                     // 分割线高度 1px

# 内外边距

// 内边距(px)
view.setPadding(16);                          // 四侧相同
view.setPadding(16, 8, 16, 8);               // 左、上、右、下
view.setPaddingHorizontal(16);               // 水平方向
view.setPaddingVertical(8);                  // 垂直方向

// 内边距(dp)
view.setPaddingDp(16);
view.setPaddingHorizontalDp(16);

// 外边距(px / dp)
view.setMargin(16);
view.setMarginDp(16);
view.setMarginTopDp(8);

// Builder 链式调用
.addTextView().paddingDp(16).marginTopDp(8)

# 可见性

// 可见性常量
int VISIBLE = 0;    // 可见
int INVISIBLE = 4;  // 不可见但占用布局空间
int GONE = 8;       // 不可见且不占用布局空间

// 设置可见性
view.setVisibility(PluginView.VISIBLE);
view.setVisible();     // 等价于 setVisibility(VISIBLE)
view.setInvisible();   // 等价于 setVisibility(INVISIBLE)
view.setGone();        // 等价于 setVisibility(GONE)

// Builder 链式调用
.addTextView("error").text("错误提示").gone()  // 初始隐藏

# 事件监听

// 点击事件
view.setOnClickListener(v -> {
    pluginUI.showToast("被点击了");
});

// 长按事件
view.setOnLongClickListener(v -> {
    pluginUI.showToast("被长按了");
    return true;  // 返回 true 表示消费事件(会有震动效果)
});

// Builder 链式调用
.addButton().text("点击").onClick(v -> { ... })
.addTextView().text("长按试试").onLongClick(v -> { return true; })

# 相关接口