← 返回索引

范围:新组件添加、自定义容器编写、覆盖层与碰撞检测扩展。

前置:元素系统(基类字段与事件签名)、布局系统(measure/arrange 契约)。


添加新组件

框架内控件的添加流程固定为三步。配置了配色的控件再仿照 record 定义配色。Button 按此流程位于 component 包,包含空 measure、绘制方法、事件方法与配色 record。

步骤

  1. 选择基类:

    • Element<T>:简单组件,无工具提示、无焦点
    • TooltippingElement<T>:需要工具提示
    • FocusableElement<T>:需要键盘输入
  2. 实现必要方法:

    • measure():测量自身尺寸;固定尺寸时留空实现(Button 即如此)
    • extractRenderState():绘制组件,入口处做 visible/font 空值守卫,并先调用 layout()
    • 事件方法:按需覆写 mousePressed、pressReleased、mouseDragged、mouseScrolled、mouseEntered、mouseMoved、mouseExited(签名以 元素系统 为准;mouseReleased 是松开通知的落点,pressReleased(e, hit) 是按下会话收尾的落点,带回应的点击在 mousePressed 记录待触发状态,在 pressReleased 中按交互管理器回传的 hit 触发动作)
  3. 添加到 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 字段的组件,机制细节见 动画系统。

相关文档