1. 项目概述:打造一个“会呼吸”的富文本阅读器

在Godot引擎里做UI, RichTextLabel 节点几乎是处理带格式文本的标配。它能解析BBCode,轻松实现加粗、变色、链接,功能很强大。但如果你用它来显示长段的对话日志、任务描述或者聊天记录,很快就会发现一个痛点: 原生的滚动体验太“硬”了 。默认情况下,它要么靠 scroll_active scroll_following 这种比较机械的方式,要么就得自己写代码去控制 scroll_to_line ,缺乏那种流畅、跟手的交互感。

我们想要的是一个更接近现代应用体验的富文本阅读器。想象一下:你可以用鼠标左键按住内容区域,像拖动网页一样上下滑动;松开鼠标,内容会根据惯性继续滚动一段距离然后缓缓停下。同时,键盘的方向键(上/下)也能精确地控制滚动,方便键盘党操作。当有新内容不断追加到底部时(比如聊天消息),我们希望视图能自动“吸附”到底部,确保最新内容可见;而当用户主动向上滚动查看历史时,又能自动解除吸附,不打扰阅读。最后,或许还需要一个“自动滚动”的动画,让内容平滑地移动到指定位置。

这个项目,就是要把这些功能全部整合到一个增强版的 RichTextLabel 中。它不仅仅是功能的堆砌,更是对用户体验的细致打磨。下面,我将从设计思路到代码实现,完整拆解如何打造这样一个“会呼吸”的富文本组件。

2. 核心设计思路与架构拆解

2.1 需求分析与技术选型

首先,我们需要明确每个功能点的核心诉求和技术实现路径:

  1. 鼠标拖拽滚动 :这本质上是将鼠标在屏幕上的垂直位移,实时转换为 RichTextLabel 的滚动偏移量。关键在于捕获 _gui_input 事件,区分“点击”和“拖拽”意图,并计算平滑的位移。
  2. 方向键滚动 :监听键盘输入,将按键事件映射为固定的滚动步长。这里需要考虑按键重复(按住不放)的处理和滚动边界限制。
  3. 底部吸附 :这是一个状态机问题。我们需要判断何时应该吸附(新内容添加且用户已在底部附近),何时应该解除吸附(用户主动向上滚动)。核心是监听内容高度变化和当前的滚动位置。
  4. 自动滚动 :这是一个动画问题。需要将滚动到某个位置(比如底部或特定行)的过程,用一个插值动画(Tween)来平滑完成,而不是瞬间跳转。

Godot 4.x版本提供了强大的 Control 节点事件系统和 Tween 动画引擎,这让我们完全可以在单个场景或脚本内实现所有功能,无需依赖复杂的插件。我选择创建一个继承自 RichTextLabel 的自定义节点( AdvancedRichTextLabel.gd ),这样既能复用所有原生功能,又能无缝添加我们的增强逻辑。

2.2 整体状态管理与信号设计

一个健壮的系统需要清晰的状态管理。我们主要维护几个核心状态:

  • is_dragging :布尔值,表示用户是否正在拖拽。
  • drag_start_position :Vector2,记录拖拽开始时鼠标的位置。
  • drag_start_scroll :浮点数,记录拖拽开始时的滚动偏移量。
  • is_locked_to_bottom :布尔值,表示当前是否处于“底部吸附”状态。
  • target_scroll :浮点数,用于自动滚动的目标值。

信号(Signal)是Godot中解耦的利器。我们的自定义节点应该对外抛出一些有用的信号,例如:

  • scroll_started() :当用户开始拖拽或自动滚动开始时触发。
  • scroll_ended() :当滚动停止时触发。
  • bottom_lock_changed(is_locked: bool) :当底部吸附状态改变时触发。

这样,父节点或其他脚本可以监听这些信号,做出相应的UI反馈(如显示/隐藏滚动条提示)。

3. 核心功能实现细节与代码解析

接下来,我们深入到每个功能的代码实现层面。我会先给出关键代码片段,然后解释其原理和注意事项。

3.1 鼠标拖拽滚动的实现

拖拽滚动的核心在于 _gui_input(event: InputEvent) 函数。我们需要处理 InputEventMouseButton (鼠标按下/松开)和 InputEventMouseMotion (鼠标移动)事件。

# AdvancedRichTextLabel.gd
extends RichTextLabel

var is_dragging := false
var drag_start_position := Vector2.ZERO
var drag_start_scroll := 0.0

func _gui_input(event: InputEvent) -> void:
    # 处理鼠标按钮事件
    if event is InputEventMouseButton:
        var mb_event := event as InputEventMouseButton
        # 检查是否在文本显示区域内点击
        if _is_point_in_text_rect(get_local_mouse_position()):
            if mb_event.button_index == MOUSE_BUTTON_LEFT:
                if mb_event.pressed:
                    # 鼠标按下:开始拖拽
                    _start_drag(mb_event.position)
                else:
                    # 鼠标松开:结束拖拽
                    _end_drag()
    
    # 处理鼠标移动事件
    elif event is InputEventMouseMotion and is_dragging:
        _process_drag(event.relative)

func _start_drag(position: Vector2) -> void:
    is_dragging = true
    drag_start_position = position
    drag_start_scroll = scroll_vertical
    # 可以在这里发射 scroll_started 信号
    emit_signal("scroll_started")

func _process_drag(relative_motion: Vector2) -> void:
    # 关键:将鼠标的垂直移动量,反向应用到滚动位置
    # 鼠标向下移动(relative_motion.y为正),内容应向上滚动(scroll_vertical减小)
    var new_scroll = drag_start_scroll - relative_motion.y
    scroll_vertical = clamp(new_scroll, 0, _get_max_scroll())
    # 注意:一旦用户开始手动拖拽,就应解除底部吸附
    is_locked_to_bottom = false

func _end_drag() -> void:
    if is_dragging:
        is_dragging = false
        # 这里可以添加惯性滚动的逻辑(后续扩展)
        emit_signal("scroll_ended")

func _is_point_in_text_rect(point: Vector2) -> bool:
    # 一个简单的判断,确保点击在文本内容区域,而不是边距或背景
    var text_rect := Rect2(Vector2.ZERO, size)
    # 可以考虑减去一些padding
    return text_rect.has_point(point)

注意事项与心得:

  • 坐标转换 event.position 获取的是全局坐标,而 scroll_vertical 是节点内部的局部滚动值。上述代码在 _start_drag 中直接使用了 mb_event.position ,这在实际应用中可能有问题,因为如果节点有嵌套或偏移,需要用到 get_local_mouse_position() make_input_local(event).position 来获取正确的本地坐标。我在 _gui_input 的开头使用了 get_local_mouse_position() 进行区域判断,但在记录起始位置时,更严谨的做法是记录本地坐标。
  • 拖拽灵敏度 :直接使用 relative_motion.y 可能太快或太慢。你可以引入一个 drag_sensitivity 系数(如0.5到1.5)进行调节: scroll_vertical = drag_start_scroll - relative_motion.y * drag_sensitivity
  • 惯性滚动(高级) _end_drag 函数是实现惯性滚动的绝佳位置。你可以记录松开鼠标前瞬间的速度,然后使用 Tween 模拟一个减速运动。这会让体验更接近手机或触控板。实现起来稍复杂,但体验提升巨大。

3.2 方向键滚动的实现

键盘滚动相对直接,我们需要在 _input(event) _unhandled_input(event) 中处理。为了不影响其他节点的输入处理,通常使用 _unhandled_input

# 在 AdvancedRichTextLabel.gd 中继续添加
@export var keyboard_scroll_speed: float = 50.0 # 每次按键滚动的像素数

func _unhandled_input(event: InputEvent) -> void:
    if not has_focus():
        return # 只有当此控件获得焦点时,才响应方向键滚动
    
    if event is InputEventKey and event.pressed:
        var key_event := event as InputEventKey
        var scroll_delta := 0.0
        
        match key_event.keycode:
            KEY_UP:
                scroll_delta = -keyboard_scroll_speed
            KEY_DOWN:
                scroll_delta = keyboard_scroll_speed
            _:
                return # 不是我们关心的键,直接返回
        
        # 应用滚动
        var new_scroll = scroll_vertical + scroll_delta
        scroll_vertical = clamp(new_scroll, 0, _get_max_scroll())
        # 同样,手动键盘滚动也解除底部吸附
        is_locked_to_bottom = false
        
        # 接受事件,防止继续传递
        get_viewport().set_input_as_handled()

实操要点:

  • 控件焦点 has_focus() 判断至关重要。你总不希望玩家在游戏里按方向键移动角色时,UI日志却在疯狂滚动。通常需要通过 mouse_filter = MOUSE_FILTER_PASS focus_mode = FOCUS_CLICK 来确保控件能被点击并获得焦点。
  • 滚动边界 _get_max_scroll() 是一个需要自己实现的辅助函数,用于计算最大可滚动值。它大致等于 (get_content_height() - size.y) ,但 get_content_height() RichTextLabel 中并不直接存在。一个可靠的方法是使用 get_line_count() * get_line_height() 进行估算,或者更精确地,在 _ready() 后连接 text_changed 信号,通过 get_v_scroll_bar().max_value 来获取(如果显示了滚动条)。
  • 按键重复 :Godot的 InputEventKey 事件在按住键时会持续触发, event.pressed 在重复触发时为 true event.echo true 。上述代码处理了重复,滚动是连续的。如果你希望每次按键只滚动一行,可以检查 !event.echo

3.3 底部吸附逻辑的精妙实现

底部吸附是体验的关键,逻辑需要既智能又无感。

# AdvancedRichTextLabel.gd
@export var auto_lock_threshold: float = 20.0 # 距离底部多少像素内视为“在底部”
var is_locked_to_bottom := true # 默认开启吸附
var previous_content_height := 0.0

func _ready() -> void:
    # 初始化内容高度记录
    previous_content_height = _get_content_height_estimate()
    # 监听文本变化
    text_changed.connect(_on_text_changed)

func _on_text_changed() -> void:
    var current_height = _get_content_height_estimate()
    
    # 检查内容是否变高(有新内容添加)
    if current_height > previous_content_height:
        # 判断用户是否“在底部”
        if _is_user_near_bottom():
            # 执行吸附滚动
            _scroll_to_bottom_smoothly()
            is_locked_to_bottom = true
        else:
            # 用户已滚动上去查看历史,解除吸附
            is_locked_to_bottom = false
    
    previous_content_height = current_height

func _is_user_near_bottom() -> bool:
    var max_scroll = _get_max_scroll()
    if max_scroll <= 0:
        return true # 内容不足一屏,自然在底部
    # 计算当前滚动位置距离底部的距离
    var distance_to_bottom = max_scroll - scroll_vertical
    return distance_to_bottom <= auto_lock_threshold

func _scroll_to_bottom_smoothly() -> void:
    var target = _get_max_scroll()
    # 使用Tween创建平滑滚动动画
    var tween = create_tween()
    tween.tween_property(self, "scroll_vertical", target, 0.2).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)

深度解析与避坑指南:

  • “在底部”的判定 auto_lock_threshold 这个阈值非常重要。设得太小(如1像素),用户稍微往上滑一点就会解除吸附,体验很“跳”。设得太大(如100像素),用户可能根本没看到最新内容,系统却以为他在底部而不再自动滚动。 20-30像素 是一个经过验证的比较舒适的区间,大约是一行文字的高度。
  • 内容高度估算 _get_content_height_estimate() 是难点。 RichTextLabel 没有直接属性。除了之前提到的通过滚动条最大值,另一个更稳定的方法是利用 get_parsed_text() 配合 get_theme_font(“normal_font”) get_theme_font_size(“font_size”) 进行手动计算,但这很复杂。 实践中最可靠且简单的方法是:临时显示垂直滚动条,读取其 max_value ,然后再隐藏它。 虽然有点“黑魔法”,但在 text_changed 的瞬间操作,用户通常感知不到。
    func _get_content_height_estimate() -> float:
        # 方法:临时获取滚动条信息
        var v_scroll := get_v_scroll_bar()
        if v_scroll:
            return v_scroll.max_value + size.y # max_value 是可滚动区域,加上可视高度才是总内容高
        # 备用方法:基于行数估算(不精确)
        return get_line_count() * (get_theme_font_size("normal_font_size") + 4)
    
  • 性能考量 text_changed 信号在每次文本变动(即使是追加一个字符)时都会触发。如果在 _on_text_changed 中进行复杂的计算或频繁创建Tween,可能会影响性能。对于高速追加内容的场景(如实时日志),可以考虑使用一个计时器( Timer )进行防抖(Debounce),比如每0.1秒检查并执行一次吸附逻辑,而不是实时响应。

3.4 平滑的自动滚动动画

自动滚动不仅用于底部吸附,也可以作为一个独立功能,例如滚动到特定行、或者点击一个“回到最新”按钮。

# AdvancedRichTextLabel.gd
@export var scroll_animation_duration: float = 0.3
@export var scroll_animation_transition: Tween.TransitionType = Tween.TRANS_QUAD
@export var scroll_animation_ease: Tween.EaseType = Tween.EASE_OUT

var current_auto_scroll_tween: Tween = null

func scroll_to_line_smoothly(line: int) -> void:
    # 先取消可能正在进行的上一个动画
    if current_auto_scroll_tween and current_auto_scroll_tween.is_valid():
        current_auto_scroll_tween.kill()
    
    var target_pixel = _estimate_pixel_from_line(line)
    var max_scroll = _get_max_scroll()
    var final_target = clamp(target_pixel, 0, max_scroll)
    
    current_auto_scroll_tween = create_tween()
    current_auto_scroll_tween.tween_property(self, "scroll_vertical", final_target, scroll_animation_duration)\
        .set_trans(scroll_animation_transition)\
        .set_ease(scroll_animation_ease)
    current_auto_scroll_tween.finished.connect(_on_auto_scroll_finished)
    
    # 自动滚动时,根据目标位置决定是否锁定底部
    is_locked_to_bottom = (final_target >= max_scroll - auto_lock_threshold)

func scroll_to_bottom_smoothly() -> void:
    scroll_to_line_smoothly(get_line_count())

func _on_auto_scroll_finished() -> void:
    current_auto_scroll_tween = null
    emit_signal("scroll_ended")

func _estimate_pixel_from_line(line: int) -> float:
    # 估算某一行顶部所在的像素位置
    # 这同样是个估算,因为行高可能不同(比如图片、自定义字体)
    var line_height_estimate = get_theme_font_size("normal_font_size") + get_theme_constant("line_separation")
    return line * line_height_estimate

经验之谈:

  • 动画中断处理 current_auto_scroll_tween 这个引用非常关键。如果用户在自动滚动过程中又开始拖拽,或者触发了一次新的自动滚动,我们必须有能力中断( kill() )之前的动画,否则会出现两个动画竞争 scroll_vertical 属性的诡异现象。
  • 属性动画 vs 方法动画 :我们使用 tween_property 直接动画化 scroll_vertical 属性。这是最简洁的方式。Godot的Tween会自己计算每一帧的插值。确保你的属性有正确的setter/getter( scroll_vertical 是内置属性,没问题)。
  • 缓动函数(Easing)选择 Tween.EASE_OUT 是最适合滚动动画的,它让滚动在结束时缓慢停止,模拟了物理世界的“减速”感,比线性的 EASE_IN_OUT EASE_IN 体验更好。 TRANS_QUAD TRANS_CUBIC 能提供更明显的加减速效果。

4. 集成、优化与高级特性

将上述所有功能模块整合到一个脚本中后,还需要考虑一些整体性的优化和扩展点。

4.1 输入处理的协调与冲突解决

当鼠标拖拽、方向键滚动和自动滚动动画同时可能发生时,需要有优先级或互斥逻辑。

  • 拖拽优先 :当 is_dragging true 时,应暂时禁用方向键滚动的影响(虽然它们可能来自不同输入源,但逻辑上用户的手动拖拽意图最强)。可以在 _unhandled_input 的开头检查 if is_dragging: return
  • 动画状态 :当 current_auto_scroll_tween 正在运行时,如果用户开始拖拽,我们应该立即 kill() 这个Tween,将控制权交还给用户。这已经在 scroll_to_line_smoothly 的开始部分通过检查并结束旧Tween实现了。
  • 焦点管理 :为了方向键滚动生效,控件需要获得焦点。一个常见的UI模式是:当用户点击(即使是为了拖拽) RichTextLabel 时,就自动获取焦点。这可以在 _gui_input 的鼠标按下事件中通过 grab_focus() 实现。

4.2 性能优化与信号管理

  • 避免每帧计算 _process _physics_process 中不要进行重计算。我们的逻辑基本都是事件驱动的(输入、文本变化)。
  • 信号连接与断开 :在 _ready 中连接信号,在 _exit_tree 或节点销毁时,如果创建了Tween,最好显式地 tween.kill() 并断开 finished 信号连接,防止内存泄漏。
  • 滚动条显隐 :原生的滚动条可能会干扰我们的自定义拖拽体验。你可以将 scroll_active 设为 false ,并完全隐藏滚动条(在主题中设置),或者创建一个更美观的自定义滚动条UI,其位置与我们的 scroll_vertical 属性同步。

4.3 扩展功能设想

一个强大的组件可以进一步扩展:

  1. 滚动条同步与交互 :实现一个自定义的滚动条节点,其 value 与我们的 scroll_vertical 双向绑定,并且可以拖动这个滚动条来滚动内容。
  2. 滚动事件暴露 :除了开始/结束,还可以发射 scroll_updated(value: float) 信号,方便外部UI(如阅读进度指示器)进行更新。
  3. 滚动到特定BBCode或链接 :增强 scroll_to_line_smoothly ,使其能解析文本,找到特定的 [url=...] 或自定义标签所在的位置并进行滚动。
  4. 触摸屏优化 :针对移动设备,可以集成 InputEventScreenTouch InputEventScreenDrag 事件,实现多点触控的捏合缩放(虽然对于纯文本阅读可能不是刚需)。

5. 常见问题排查与调试技巧

在实际集成和使用过程中,你可能会遇到以下问题:

问题1:鼠标拖拽时,滚动非常卡顿或跳跃。

  • 排查 :检查是否在 _process 中做了不必要的重绘或计算。确保 _process_drag 中的计算是轻量的。可能是 _get_max_scroll() 计算开销太大,尝试缓存它的值,只在文本变化时更新。
  • 解决 :在 _process_drag 中,直接使用 relative_motion.y ,避免在函数内调用任何可能触发布局重算的Godot API。

问题2:底部吸附功能时灵时不灵,有时新消息来了却不自动滚动下去。

  • 排查 :首先打印调试信息。在 _on_text_changed 中加入 print(“内容高: ”, current_height, “, 之前高: ”, previous_content_height, “, 近底部? ”, _is_user_near_bottom()) 。很可能 _is_user_near_bottom() 的判断条件因为 _get_max_scroll() 计算不准而失效。
  • 解决 :采用“临时获取滚动条max_value”法来计算最大滚动值和内容高度。这是最准确的方法。

问题3:方向键滚动时,同时触发了场景中其他节点的输入事件(比如角色移动)。

  • 排查 :确认你的 AdvancedRichTextLabel 是否通过 grab_focus() 正确获得了焦点。检查 _unhandled_input 中是否在处理后调用了 get_viewport().set_input_as_handled()
  • 解决 :确保UI控件的 focus_mode 不为 FOCUS_NONE ,并在鼠标按下时调用 grab_focus() 。在 _unhandled_input 处理完按键事件后,一定要 set_input_as_handled()

问题4:自动滚动动画结束时,有时会轻微地“回弹”一下。

  • 排查 :这通常是因为动画的最终目标值 target scroll_vertical 属性的实际有效范围有细微出入。例如, _get_max_scroll() 返回的值可能略小于实际可滚动的最大值。
  • 解决 :在动画结束的回调函数 _on_auto_scroll_finished 中,强制将 scroll_vertical 设置为最终目标值 clamp(scroll_vertical, 0, _get_max_scroll()) ,做一次修正。

问题5:在复杂的UI布局中,拖拽区域判断 _is_point_in_text_rect 不准。

  • 排查 get_local_mouse_position() 返回的是相对于该节点原点的坐标。如果你的 RichTextLabel 有样式盒(StyleBox)带来的边距(margin),或者父控件有裁剪,这个矩形区域需要调整。
  • 解决 :更健壮的方法是使用 get_global_rect() 获取控件全局矩形,然后与 get_global_mouse_position() 对比。或者,直接利用Godot的 Control 节点的 mouse_filter 属性。如果设置为 MOUSE_FILTER_PASS ,那么该节点本身不会吞噬鼠标事件,事件会传递给其下的子节点或父节点,这可能不是我们想要的。对于需要全区域拖拽的阅读器,通常设置为 MOUSE_FILTER_STOP 并在 _gui_input 中处理所有事件即可,无需精细的点判断,除非你需要区分点击文本和点击空白区域的不同行为。

将这个增强版的 RichTextLabel 投入项目使用后,最直接的感受就是UI的交互质感上了一个台阶。它不再是一个冰冷的文本显示框,而是一个能响应用户意图、有动效、有状态的活控件。尤其是在制作叙事游戏、模拟经营游戏的日志系统或任何需要频繁浏览长文本的场景下,这种流畅的滚动体验对维持玩家沉浸感有莫大帮助。代码量虽然比原生节点多了不少,但模块清晰,每个功能块都对应着明确的用户体验目标,维护和扩展起来也并不困难。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐