范围:新组件添加、自定义容器编写、覆盖层与碰撞检测扩展。
前置:元素系统(基类字段与事件签名)、布局系统(measure/arrange 契约)。
添加新组件
框架内控件的添加流程固定为三步。配置了配色的控件再仿照 record 定义配色。Button 按此流程位于 component 包,包含空 measure、绘制方法、事件方法与配色 record。
步骤
-
选择基类:
Element<T>:简单组件,无工具提示、无焦点TooltippingElement<T>:需要工具提示FocusableElement<T>:需要键盘输入
-
实现必要方法:
measure():测量自身尺寸;固定尺寸时留空实现(Button即如此)extractRenderState():绘制组件,入口处做visible/font空值守卫,并先调用layout()- 事件方法:按需覆写
mousePressed、pressReleased、mouseDragged、mouseScrolled、mouseEntered、mouseMoved、mouseExited(签名以 元素系统 为准;mouseReleased是松开通知的落点,pressReleased(e, hit)是按下会话收尾的落点,带回应的点击在mousePressed记录待触发状态,在pressReleased中按交互管理器回传的hit触发动作)
-
添加到 component 包:
xianfish.fluentui.client.ui.component
带配色的组件仿照 Button 定义 XxxColors record,并提供 blue() 默认配色工厂;构造函数给出「带 colors」与「不带 colors」两组重载(String 与 Component 各一份)。
输入类控件、按钮类控件与浮层/列表面板共用一套配色口径:底色 0xFF222222、常态边框 0xFF555555、悬停边框 0xFF777777、聚焦/激活色 0xFF3B82F6、选区/行高亮底 0x403B82F6、正文 0xFFFFFFFF、禁用正文 0x80FFFFFF,一律全不透明。这组值分布在八个颜色 record 中:TextBoxColors、SpinColors、AutoColors、DropdownColors、ContextMenuColors、ListViewColors、ButtonColors、ToggleColors(Colors 尚无对应的语义常量)。AutoColors 与 DropdownColors 的下拉面板字段、ContextMenuColors 与 ListViewColors 的面板/行字段都取同一组;DropdownColors.btnFocusBorder 由「面板展开」充当本控件的激活态;ButtonColors 的常态边框在 rest 态即描出,悬停时提亮到 0xFF777777、底色提升到 0xFF2E2E2E;ToggleColors 的关态轨道直接使用共用底色。ItemList 的选中行底色是内联字面量 0x403B82F6,不经过 record。新的输入类控件、按钮类控件与浮层面板采用这组取值,不另设一套;由于这些字面量分散持有,同步修改属于维护约定而非编译约束,核对清单为七个色位与八个 record 的组合,外加 ItemList 那一处内联值。少数值不在此口径内:SelectBox 的 0xFF2A2A2A/0xFF444444/0xFF333333、Stepper 的左右钮底 0x30000000 与百分比条 0x803B82F6、Slider 的轨道 0x30FFFFFF 与滑块高亮 0xFF93C5FD、ProgressBar 的自身强调色;这些取值属于小尺寸控件或控件内部件,与输入框那一套不同源,修改前需确认其归属类别。
示例
色板组件完整实现三步:固定尺寸,因此 measure 留空;绘制在 extractRenderState 中完成;点击按下时记录状态,经 pressReleased 触发动作,悬停由事件方法处理。
public class ColorSwatch extends Element<ColorSwatch> {
private int color;
private final Animator hoverAnim = new Animator();
private final Animator pressAnim = new Animator();
private boolean pending;
private Runnable onClick;
public ColorSwatch(int color, int size) {
super(size, size);
this.color = color;
hoverAnim.set(0);
pressAnim.set(0);
}
public ColorSwatch onClick(Runnable r) { this.onClick = r; return this; }
@Override protected void measure() {} // 固定尺寸,无需测量
@Override
public void extractRenderState(GuiGraphicsExtractor e, int mx, int my, float d) {
if (!visible || font == null) return;
layout();
int ax = getAbsoluteX(), ay = getAbsoluteY();
int borderColor = Colors.lerp(0xFF555555, 0xFF3B82F6, hoverAnim.get());
e.fill(ax - 1, ay - 1, ax + width + 1, ay + height + 1, borderColor);
e.fill(ax, ay, ax + width, ay + height, color);
int pa = (int)(pressAnim.get() * 0x50); // 按下视觉:整块压一层半透明白
if (pa > 0) e.fill(ax, ay, ax + width, ay + height, (pa << 24) | 0xFFFFFF);
}
@Override public boolean mousePressed(MousePressedEvent e) {
if (e.button() != 0 || !InteractionManager.hovering(this, e.x(), e.y())) return false;
pending = true; // 按下只记状态,动作留给松开
pressAnim.animate(0, 1, 60, Easing.EASE_IN_CUBIC);
return true;
}
@Override public boolean pressReleased(MouseReleasedEvent e, boolean hit) {
boolean wp = pending; pending = false;
pressAnim.animate(1, 0, 100, Easing.EASE_OUT_CUBIC);
if (wp && hit && onClick != null) onClick.run(); // hit:交互管理器按完整悬停仲裁算出的命中
return wp;
}
@Override public void mouseEntered(HoverEvent e) {
hoverAnim.to(1f, 120, Easing.EASE_OUT_CUBIC);
}
@Override public void mouseExited(HoverEvent e) {
hoverAnim.to(0f, 120, Easing.EASE_OUT_CUBIC);
}
}
悬停动画有两种驱动方式,选择依据是同族组件的观感,与哪一族的观感一致即采用哪一种。色板组件用 mouseEntered/mouseExited 事件驱动 hoverAnim,这两个回调由交互引擎在光标进出时派发;Button、ItemView 则在 extractRenderState 中按帧轮询 InteractionManager.hovering(...),状态变化时调用 animate/set。两种方式均有效:事件驱动写法较短,轮询驱动对程序性状态修改更稳定。
创建自定义布局容器
内置容器不足时,继承 LayoutElement 实现自定义容器。契约为 measure 只计算尺寸、arrange 只设置位置、绘制由子元素递归完成,公式细节见 布局系统。自定义容器的骨架按此契约编写;children 是 LayoutElement 的受保护子列表,child.layout() 用于在读尺寸前完成子元素计算:
public class MyLayout extends LayoutElement<MyLayout> {
private boolean autoWidth, autoHeight;
@Override protected void measure() {
// 计算子元素所需的总尺寸
int totalWidth = 0;
int maxHeight = 0;
for (Element<?> child : children) {
if (!child.isVisible()) continue;
child.layout();
totalWidth += child.getWidth() + child.getMargin().horizontal();
maxHeight = Math.max(maxHeight, child.getHeight() + child.getMargin().vertical());
}
if (autoWidth) width = totalWidth;
if (autoHeight) height = maxHeight;
}
@Override protected void arrange() {
// 定位子元素
int cursor = 0;
for (Element<?> child : children) {
if (!child.isVisible()) continue;
Margin m = child.getMargin();
child.setPos(cursor + m.left(), m.top());
cursor += child.getWidth() + m.horizontal();
}
}
}
autoWidth/autoHeight 未开启时,容器保持构造时给定的尺寸,measure 只读不写。measure 中先调用 child.layout(),因为子元素可能刚被修改且尚未重排,未先完成计算时读到的是旧尺寸。机制细节对照 布局系统。
扩展碰撞检测
自定义命中形状(例如圆角矩形、开关门限)时,覆写 shapeHit:
@Override public boolean shapeHit(double mx, double my) {
int ax = getAbsoluteX(), ay = getAbsoluteY();
// 检查点是否在圆角矩形内
return isInsideRoundedRect(mx, my, ax, ay, width, height, cornerRadius);
}
shapeHit 是 H1,即唯一允许覆写的命中点,且只涉及「形状」,不涉及裁剪与树。isHit 是静态的 L1 统一谓词(形状命中 ∩ 祖先裁剪链),内部经由 shapeHit 取值;覆写 shapeHit 后,分发门控、外部关闭与阻断判定自动采用新的形状。isHit 无需也不可覆盖:它是静态方法,本就无法覆写。完整分层表(H0/H1/L1/H3/L3 各管什么)见 交互引擎。
覆写后,「圆角外、外接矩形内」的角落不应产生命中响应。
以当前值绘制位置的滑块,只修改目标值会产生缓动滞后,拖动过程无法跟随指针。
鼠标捕获独占
点击分发与滚轮分发采用相同结构:「命中元素优先、沿父链回退,第一个消费即截断」。组件返回 true 即截断外层分发,滚轮分发同样如此。
@Override public void mouseEntered(HoverEvent e) {
super.mouseEntered(e);
InteractionManager im = InteractionManager.active;
if (im != null) im.requestMouseCapture(this); // 捕获在谁身上,滚轮/拖动就只发给谁
}
@Override public void mouseExited(HoverEvent e) {
super.mouseExited(e);
InteractionManager im = InteractionManager.active;
if (im != null && im.mouseCaptureOwner() == this) im.releaseMouseCapture(); // 只释放自己那份
}
申请时机:必须在 mousePressed 中调用 requestMouseCapture;悬停期调用只写入 pendingCapture,不会激活。释放条件:释放前必须核对 mouseCaptureOwner() == this,否则会清除其他组件(例如拖拽中的滑块)正在使用的捕获;捕获为全局单例,同一时刻只有一个持有者。
同一机制用于拖拽类独占(例如 DraggablePanel 将面板拖出容器边界)。滚轮越级不需要捕获,因为滚轮分发本身即截断,Stepper 消费后分发停止。
层级问题优先以「列表位置」解决:需要覆盖容器内其他内容时,将元素放入 children 末位,因为框架口径是「列表位置即层级」,命中查找逆序扫描,先扫描到末位。容器侧另外需要注意:拖动时同时写入滚动当前值与目标值(scrollX/scrollY 与 targetScrollX/targetScrollY),因为滑块位置由当前值绘制,只修改目标值会产生缓动滞后,拖动过程无法跟随指针。
添加新的覆盖层类型
自定义浮层通过继承 Element 创建,并经 OverlayManager.push 推入。浮层不是布局容器(直接挂载于 Element,没有子列表),因此内容面板以字段持有,坐标链手动建立,事件手动转发:
public class CustomPopup extends Element<CustomPopup> {
private StackPanel content;
public CustomPopup() {
// 必须给尺寸:默认构造是 0×0,浮层永远命中不到
super(200, 150);
content = new StackPanel(Orientation.VERTICAL)
.size(200, 150)
.bgColor(0xFF1E1E1E);
content.setParent(this); // 只挂回指:坐标链与裁剪链经此走到浮层本体
}
@Override
public void extractRenderState(GuiGraphicsExtractor e, int mx, int my, float d) {
if (!visible || font == null) return; // font 为空时守卫直接跳过绘制
content.extractRenderState(e, mx, my, d);
}
@Override public boolean mousePressed(MousePressedEvent e) {
if (Element.isHit(content, e.x(), e.y())) {
// StackPanel(LayoutElement)收到后会按 Hit 自己递归分发给命中的子控件
return content.mousePressed(e);
}
return false;
}
}
先设置位置,再推入。overlays().push 的重载依次为 push(child)(默认 dismissOnOutsideClick=true)与 push(child, dismissOnOutsideClick),传入 false 表示「点外部不自动关闭」:
CustomPopup popup = new CustomPopup().font(font);
popup.setPos(x, y);
InteractionManager.active.overlays().push(popup, false);
浮层本身只提供 dismissOnOutsideClick 开关与 removeFromParent 卸载路径,不规定浮层何时推入;各子类在自己的 show()/open()/close() 中决定(参照 ContextMenu 的写法:显示时先 removeFromParent() 脱离旧布局宿主再推入,关闭时 removeFromParent() 卸载)。内容面板不使用 addChild,因为浮层没有子列表可加;坐标经 setParent 的回指链换算,事件由浮层接收后转发(LayoutElement 一侧会递归)。自测时推入浮层,点击内容中的按钮应有响应,点击浮层外按 dismissOnOutsideClick 关闭。
自定义动画效果
组件动效由三部分组成:Animator 字段、状态变化时调用 animate/to、绘制时读取 get()。骨架仅示意结构;真实缩放绘制需补全,或在覆写中组合 super.extractRenderState(...):
public class AnimatedButton extends Button {
private Animator scaleAnim = new Animator();
public AnimatedButton(String text, int w, int h) {
super(text, w, h);
}
@Override
public void extractRenderState(GuiGraphicsExtractor e, int mx, int my, float d) {
layout();
float scale = 1.0f + scaleAnim.get() * 0.1f; // 缩放 1.0 ~ 1.1
// 应用缩放绘制
// ...
}
@Override public void mouseEntered(HoverEvent e) {
scaleAnim.to(1f, 200, Easing.EASE_OUT_CUBIC);
}
@Override public void mouseExited(HoverEvent e) {
scaleAnim.to(0f, 200, Easing.EASE_OUT_CUBIC);
}
}
缓动函数
可选的缓动曲线、各自形状与默认值的取值表见 Developer: 动画;曲线取值只链接该表,不维护第二份,防止两份表漂移。Animator 的时间源与懒更新机制(为什么每帧必须调用 get())见 动画系统。
不变量
- 命中只覆写
shapeHit:rectHit(H0)不可覆写,isHit(L1)是静态谓词;绕过它们另写一套命中判断,会与分发门控、外部关闭、阻断判定脱节。 - 添加组件的三步落点不变:新组件必须进
component包、必须继承四类基类之一、必须通过measure/extractRenderState契约;不得新增第五种基类,也不得将组件放入其他包。 - 浮层不持有子列表:不得以「方便」为由为浮层添加
addChild;浮层的内容组织方式(字段持有或推入布局容器)是全框架一致的约定。 - 缓动曲线取值表单一来源:曲线取值只链 Developer: 动画,防止两份表漂移。
连带影响
- 改
measure/arrange/layout契约,全部示例与所有按此流程添加的组件一起失效,布局系统 的公式章也要同步。 - 改
shapeHit/isHit的语义或分层命名,波及碰撞检测的扩展方式与 交互引擎 的分层表;所有覆写过shapeHit的组件(如SelectDropdown的下拉面板)行为也随之变化。 - 改浮层的推入/关闭钩子(
OverlayManager.push、remove、dismissOnOutsideClick),波及CustomPopup示例与ContextMenu、SelectDropdown、AutoCompleteBox、MessageFlyout等全部浮层子类。 - 改
Animator机制,波及动画示例与所有持有Animator字段的组件,机制细节见 动画系统。
相关文档
- 元素系统:Element 基类定义与事件签名
- 布局系统:measure/arrange 契约与容器公式
- 交互引擎:碰撞分层权威(H0/H1/L1/H3/L3)
- 动画系统:Animator 机制与不变量
- Developer: 常见使用模式:用户向模式(与贡献者向模式配对阅读)
- Developer: 动画:缓动曲线取值表
- UI 组件:现成控件的构造与事件;只使用框架搭建界面时参考用户向的两篇