UI を作っていると、「装備」「アイテム」「設定」みたいなタブ切り替えって、つい TabContainer をそのまま使ったり、ボタンごとにスクリプトを書いて visible を切り替えたりしがちですよね。

でも、

  • ボタンの数や順番が変わるたびにスクリプトを書き換える
  • タブごとに違うノード構成だから TabContainer では融通が利かない
  • 「この UI でも」「あの UI でも」同じようなタブ処理をコピペしている

…という状態になりがちです。典型的な「UI ロジックがシーンにベタ書き」パターンですね。

そこで今回は、どんなレイアウトにも後付けできるコンポーネントとして、タブ切り替えロジックをひとまとめにした 「TabSwitcher」コンポーネント を用意しました。
ボタンとパネルをアサインするだけで、複数のタブ UI を使い回せるようにしていきましょう。

【Godot 4】UIタブ切り替えをコンポーネント化!「TabSwitcher」コンポーネント

このコンポーネントのゴールはシンプルです:

  • タブ用ボタン(Button / BaseButton)とパネル(任意の Control)をペアで登録
  • ボタン押下で対応パネルだけ visible = true、他は false
  • 「最初に表示するタブ」や「選択中タブのスタイル切り替え」も一箇所で管理

つまり、UI シーンのどこにでもペタっと貼れる「タブ切り替えコンポーネント」です。


フルコード:TabSwitcher.gd


extends Node
class_name TabSwitcher
## 任意のボタンとパネルをペアで登録し、
## ボタン押下に応じて表示パネルを切り替えるコンポーネント。
##
## 想定:
## - ボタン: BaseButton (Button, TextureButton, CheckBox など)
## - パネル: Control (VBoxContainer, Panel, MarginContainer など)
##
## このコンポーネント自体は Node なので、
## 任意の UI シーンの子として追加して使います。

# --- 設定用データ構造 ---

## エディタから設定しやすくするための struct 的クラス
class TabEntry:
    var button: BaseButton
    var panel: Control

    func _init(_button: BaseButton = null, _panel: Control = null) -> void:
        button = _button
        panel = _panel

## インスペクタで編集できる配列。
## 各要素に「ボタン」と「パネル」を割り当てます。
@export var tabs: Array[TabEntry] = []

## 起動時に自動で選択するタブのインデックス。
## -1 の場合は「何もしない」(自分で show_tab() を呼ぶ)。
@export_range(-1, 1024, 1)
var default_tab_index: int = 0

## 選択中タブのボタンを ToggleButton 的に押下状態にするかどうか。
## Button.toggle_mode = true を自動で設定し、pressed 状態を管理します。
@export var use_button_toggle: bool = true

## 選択中パネルのみにフォーカスを移すかどうか。
## true の場合、タブ切り替え時にそのパネル内の最初のフォーカス可能な Control にフォーカスします。
@export var focus_selected_panel: bool = false

## 無効なボタン/パネルがあったときに警告ログを出すかどうか。
@export var warn_on_invalid_entries: bool = true

# --- 内部状態 ---

## 現在選択されているタブのインデックス。何も選択されていなければ -1。
var current_index: int = -1:
    set(value):
        current_index = value
        tab_changed.emit(current_index)

## タブが切り替わったときに発火するシグナル。
## UI 側で「タブ切り替え時にサウンドを鳴らす」などの拡張ができます。
signal tab_changed(new_index: int)

func _ready() -> void:
    _connect_buttons()
    _apply_initial_state()

# --- 初期化処理 ---

## 各ボタンに pressed シグナルを接続し、押されたら対応インデックスを開くようにする。
func _connect_buttons() -> void:
    for i in tabs.size():
        var entry := tabs[i]
        if entry == null:
            if warn_on_invalid_entries:
                push_warning("TabSwitcher: tabs[%d] is null." % i)
            continue

        if entry.button == null or entry.panel == null:
            if warn_on_invalid_entries:
                push_warning("TabSwitcher: tabs[%d] has null button or panel." % i)
            continue

        # ボタンの toggle_mode を自動設定(オプション)
        if use_button_toggle:
            entry.button.toggle_mode = true

        # すでに接続済みならスキップ(エディタ上で複数回 _ready が呼ばれる対策)
        if not entry.button.is_connected("pressed", Callable(self, "_on_tab_button_pressed")):
            entry.button.pressed.connect(_on_tab_button_pressed.bind(i))

## 起動時のタブ状態を反映。
func _apply_initial_state() -> void:
    # まず全部非表示にしてボタンもオフにする
    _hide_all_panels()
    _reset_all_buttons()

    if default_tab_index >= 0 and default_tab_index < tabs.size():
        show_tab(default_tab_index)
    else:
        # 何も選択しない場合でも、current_index は -1 のままにしておく
        current_index = -1

# --- コアロジック ---

## 外部からタブを切り替えたいときに呼ぶメソッド。
## 例: tab_switcher.show_tab(2)
func show_tab(index: int) -> void:
    if index < 0 or index >= tabs.size():
        if warn_on_invalid_entries:
            push_warning("TabSwitcher.show_tab: index %d is out of range." % index)
        return

    var entry := tabs[index]
    if entry == null or entry.button == null or entry.panel == null:
        if warn_on_invalid_entries:
            push_warning("TabSwitcher.show_tab: tabs[%d] is invalid." % index)
        return

    # すでに選択中なら何もしない(連打対策)
    if current_index == index:
        return

    # すべてのパネルを隠し、ボタンもリセット
    _hide_all_panels()
    _reset_all_buttons()

    # 対象パネルだけ表示
    entry.panel.visible = true

    # ボタンの見た目を「選択中」にする
    if use_button_toggle:
        entry.button.button_pressed = true

    current_index = index

    # フォーカスを移すオプション
    if focus_selected_panel:
        _focus_first_control_in_panel(entry.panel)

## すべてのパネルを非表示にする内部メソッド。
func _hide_all_panels() -> void:
    for entry in tabs:
        if entry == null or entry.panel == null:
            continue
        entry.panel.visible = false

## すべてのボタンの pressed 状態をオフにする内部メソッド。
func _reset_all_buttons() -> void:
    for entry in tabs:
        if entry == null or entry.button == null:
            continue
        if use_button_toggle:
            entry.button.button_pressed = false

## ボタンが押されたときのハンドラ。
func _on_tab_button_pressed(index: int) -> void:
    show_tab(index)

# --- フォーカス関連ユーティリティ ---

## 指定パネル内の最初のフォーカス可能な Control にフォーカスを移す。
func _focus_first_control_in_panel(panel: Control) -> void:
    # breadth-first で子孫を探索して、最初に focus_mode != FOCUS_NONE の Control を見つける
    var queue: Array[Node] = [panel]
    while not queue.is_empty():
        var node := queue.pop_front()
        if node is Control:
            var ctrl := node as Control
            if ctrl.focus_mode != Control.FOCUS_NONE:
                ctrl.grab_focus()
                return

        for child in node.get_children():
            if child is Node:
                queue.append(child)

# --- 補助メソッド(任意で使用) ---

## 現在選択中のタブエントリを返す。なければ null。
func get_current_tab_entry() -> TabEntry:
    if current_index < 0 or current_index >= tabs.size():
        return null
    return tabs[current_index]

## 現在選択中のパネルを返す。なければ null。
func get_current_panel() -> Control:
    var entry := get_current_tab_entry()
    return entry.panel if entry != null else null

## 現在選択中のボタンを返す。なければ null。
func get_current_button() -> BaseButton:
    var entry := get_current_tab_entry()
    return entry.button if entry != null else null

使い方の手順

ここでは典型的な「ステータス画面」を例にして、「装備」「アイテム」「設定」の3タブを切り替える UI を作ってみましょう。

手順①:UI シーンを用意する

まずは、ざっくりとした UI シーンを用意します。

StatusMenu (Control)
 ├── VBoxContainer
 │    ├── HBoxContainer              # タブボタンを並べる行
 │    │    ├── EquipButton (Button)
 │    │    ├── ItemButton (Button)
 │    │    └── SettingsButton (Button)
 │    └── PanelContainer             # 中身の表示エリア
 │         ├── EquipPanel (Control)
 │         ├── ItemPanel (Control)
 │         └── SettingsPanel (Control)
 └── TabSwitcher (Node)              # ★このコンポーネントを追加

TabSwitcher はどこに置いても構いませんが、UI 全体を管理するルート(この例では StatusMenu)の子に置いておくと分かりやすいですね。

手順②:TabSwitcher をシーンに追加してスクリプトをアタッチ

  1. StatusMenu シーンを開く
  2. 子ノードとして Node を追加し、名前を TabSwitcher に変更
  3. そのノードに、先ほどの TabSwitcher.gd をアタッチ

コンポーネント指向のポイントはここで、
「タブ切り替えロジック」を特定の Control に継承で埋め込むのではなく、
どの UI にも後付けできる Node として独立させている点ですね。

手順③:インスペクタでタブのペアを設定する

TabSwitcher ノードを選択し、インスペクタから以下のように設定します。

  • tabs 配列に 3 要素を追加
  • それぞれの要素に対して:
    • button に対応するボタン(EquipButton など)
    • panel に対応するパネル(EquipPanel など)
  • default_tab_index = 0(起動時に「装備」タブを表示)
  • use_button_toggle = true(選択中タブのボタンを押下状態に)
  • focus_selected_panel = false(必要なら true にしてもOK)

設定イメージ:

  • tabs[0].button = EquipButton, tabs[0].panel = EquipPanel
  • tabs[1].button = ItemButton, tabs[1].panel = ItemPanel
  • tabs[2].button = SettingsButton, tabs[2].panel = SettingsPanel

これだけで、ボタンを押したときに対応するパネルだけが表示されるようになります。

手順④:他の UI でも再利用する

同じ TabSwitcher を、別の UI シーンでもそのまま使えます。例えば、ショップ画面:

ShopMenu (Control)
 ├── VBoxContainer
 │    ├── HBoxContainer
 │    │    ├── BuyButton (Button)
 │    │    ├── SellButton (Button)
 │    │    └── TalkButton (Button)
 │    └── PanelContainer
 │         ├── BuyPanel (Control)
 │         ├── SellPanel (Control)
 │         └── TalkPanel (Control)
 └── TabSwitcher (Node)

あとは同じように tabs にボタンとパネルをアサインするだけ。
スクリプトのコピペや継承は一切不要で、「タブ切り替え」という振る舞いをコンポーネントとして合成している形になります。


メリットと応用

TabSwitcher コンポーネントを使うことで、UI 周りがかなりスッキリします。

  • シーン構造が浅く保てる
    タブごとに別シーンを継承したり、巨大な UI シーンにロジックをベタ書きしたりせずに済みます。
    「タブ切り替え」という責務を単一の Node に閉じ込めているので、UI ルートのスクリプトは軽量なままです。
  • どんなレイアウトにも対応できる
    TabContainer のような「特定の親子構造」に縛られず、
    ボタンとパネルがどこにあっても、参照さえ渡せば動きます。
  • 使い回しがしやすい
    ステータス画面、ショップ、オプションメニューなど、
    「タブ切り替え UI」が必要なところに TabSwitcher をペタっと貼るだけで再利用できます。
  • テストやデバッグがしやすい
    タブ切り替えの挙動を変えたいときは、このコンポーネントだけ差し替えれば OK。
    各 UI シーンに散らばったロジックを追いかける必要がありません。

コンポーネント指向の良さは、「振る舞いをノードとして後付けできる」ことです。
UI ルートを StatusMenuShopMenu などそれぞれ独立させつつ、「タブ切り替え」という共通機能だけを TabSwitcher で共有する、という構成がとても取りやすくなります。

改造案:キーボードでタブを左右に移動できるようにする

例えば、キーボード操作で ← / → キーを押したときに、タブを順送り・逆送りしたくなることがあります。
そんなときは、TabSwitcher に次のようなメソッドを追加してみましょう。


## キーボードやゲームパッドからタブを移動するためのヘルパー。
func move_tab(offset: int) -> void:
    if tabs.is_empty():
        return

    var new_index := current_index
    if new_index < 0:
        new_index = 0
    else:
        new_index = (new_index + offset) % tabs.size()
        if new_index < 0:
            new_index += tabs.size()

    show_tab(new_index)

あとは UI ルート側(例: StatusMenu.gd)で _unhandled_input を拾って、


func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_left"):
        $TabSwitcher.move_tab(-1)
        get_viewport().set_input_as_handled()
    elif event.is_action_pressed("ui_right"):
        $TabSwitcher.move_tab(1)
        get_viewport().set_input_as_handled()

のように呼び出せば、キーボードやゲームパッドからも快適にタブを切り替えられます。

こうして「タブ切り替え」という責務をコンポーネントとして切り出しておくと、
後から機能追加したいときにも、UI 全体ではなくコンポーネントだけを育てていけるのがいいところですね。