Godot で UI を作っていると、Control をドラッグで動かしたい場面ってけっこうありますよね。
でも素直にやろうとすると、

  • Control を継承したカスタムクラスを毎回作る
  • ドラッグ処理を各 UI ごとにコピペして散らばる
  • 「このウィンドウはヘッダーだけ掴める」「これは全体を掴める」みたいな差分が地味に面倒

みたいな「小さいけどダルい問題」にハマりがちです。

そこで今回は、どんな UI にも後付けできる「ドラッグ移動コンポーネント」を用意して、
「継承ベース」ではなく「コンポーネントをアタッチするだけ」でドラッグ移動を実現してみましょう。

【Godot 4】どのUIもつかんで動かせ!「Draggable」コンポーネント

この Draggable コンポーネントは、

  • 自分の親の Control をマウスドラッグで動かす
  • 画面内に収まるように自動で制限する(オプション)
  • クリックしてもボタンなどの UI の通常動作を邪魔しない(オプション)

といった機能を持っています。
つまり「ドラッグしたい UI の子ノードとして置くだけ」で、どんな UI でもドラッグ可能にできるわけですね。


フルコード(GDScript / Godot 4)


extends Node
class_name Draggable
## 親の Control ノードをマウスドラッグで動かすコンポーネント
##
## 使い方:
## - 親が Control のシーンに、この Draggable を子として追加するだけ
## - 親 Control の位置がドラッグで動くようになります

@export var enabled: bool = true:
	set(value):
		enabled = value
		# ドラッグ中に無効化されたら強制終了
		if not enabled and _is_dragging:
			_is_dragging = false

## どこを掴めるか:
## - null: 親 Control 全体がドラッグ可能
## - NodePath: 指定した子 Control 上だけドラッグ可能(ウィンドウのヘッダーなど)
@export var drag_handle_path: NodePath

## 画面(ビューポート)外への移動を制限するかどうか
@export var clamp_to_viewport: bool = true

## ドラッグ開始/終了をログに出したいとき用
@export var debug_log: bool = false

## ドラッグ対象(親の Control)
var _target: Control
## 掴んだ時点でのマウス位置(グローバル)
var _drag_start_mouse_pos: Vector2
## 掴んだ時点でのターゲット位置(グローバル)
var _drag_start_target_pos: Vector2
## ドラッグ中フラグ
var _is_dragging: bool = false
## 実際にマウスイベントを受け取る Control(ハンドル)
var _handle_control: Control


func _ready() -> void:
	# 親が Control であることを確認
	_target = get_parent() as Control
	if _target == null:
		push_warning("Draggable: 親が Control ではありません。このコンポーネントは Control の子として使ってください。")
		set_process(false)
		set_process_input(false)
		return

	# ドラッグ用のハンドルを解決
	if drag_handle_path == NodePath(""):
		# ハンドル未指定: 親 Control 全体を掴めるようにする
		_handle_control = _target
	else:
		var node := get_node_or_null(drag_handle_path)
		_handle_control = node as Control
		if _handle_control == null:
			push_warning("Draggable: drag_handle_path に指定されたノードが Control ではありません。親 Control 全体をハンドルとして使います。")
			_handle_control = _target

	# 入力を受け取るためにマウスフィルターを調整
	# ハンドルが親そのものの場合は、既存の設定を尊重する
	if _handle_control != _target:
		# ハンドル専用のときは確実にイベントを取りたいので STOP にしておく
		_handle_control.mouse_filter = Control.MOUSE_FILTER_STOP

	set_process_input(true)


func _input(event: InputEvent) -> void:
	if not enabled:
		return
	if _handle_control == null or _target == null:
		return

	# マウスイベントのみ処理
	if not (event is InputEventMouseButton or event is InputEventMouseMotion):
		return

	# グローバル座標系で処理する
	var viewport := get_viewport()
	if viewport == null:
		return

	# ボタンイベント(左クリックで掴む/離す)
	if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
		var mb := event as InputEventMouseButton

		if mb.pressed:
			# クリック位置がハンドル内かどうか判定
			var mouse_pos: Vector2 = mb.position
			# Control のグローバル矩形を取得
			var global_rect: Rect2 = _handle_control.get_global_rect()

			if global_rect.has_point(mouse_pos):
				_start_drag(mouse_pos)
				# 他の UI にイベントを渡したくない場合はここで return してもよい
				# 今回は「ドラッグしつつボタンも押せる」ようにしたいのでそのまま続行
		else:
			# 左ボタンを離したらドラッグ終了
			if _is_dragging:
				_end_drag()

	# マウス移動イベント
	elif event is InputEventMouseMotion:
		if _is_dragging:
			var mm := event as InputEventMouseMotion
			_update_drag(mm.position)


func _start_drag(mouse_global_pos: Vector2) -> void:
	_is_dragging = true
	_drag_start_mouse_pos = mouse_global_pos
	_drag_start_target_pos = _target.global_position

	if debug_log:
		print("Draggable: drag start at ", mouse_global_pos, " target_pos=", _drag_start_target_pos)


func _update_drag(mouse_global_pos: Vector2) -> void:
	# マウスの移動量に応じてターゲットを動かす
	var delta: Vector2 = mouse_global_pos - _drag_start_mouse_pos
	var new_pos: Vector2 = _drag_start_target_pos + delta

	if clamp_to_viewport:
		new_pos = _clamp_position_to_viewport(new_pos)

	_target.global_position = new_pos


func _end_drag() -> void:
	_is_dragging = false
	if debug_log:
		print("Draggable: drag end. final_pos=", _target.global_position)


func _clamp_position_to_viewport(pos: Vector2) -> Vector2:
	# ターゲットのサイズとビューポートサイズを元に、画面内に収める
	var viewport := get_viewport()
	if viewport == null:
		return pos

	var vp_size: Vector2 = viewport.get_visible_rect().size
	var size: Vector2 = _target.size

	var clamped := pos
	clamped.x = clampf(clamped.x, 0.0, max(0.0, vp_size.x - size.x))
	clamped.y = clampf(clamped.y, 0.0, max(0.0, vp_size.y - size.y))
	return clamped

使い方の手順

ここからは、具体的なシーン例を交えつつ、実際の使い方を見ていきましょう。

例1: シンプルなドラッグ可能ウィンドウ

「ウィンドウ全体を掴んで動かせる」パターンです。

WindowPanel (Panel)  ※親が Control であることが重要
 ├── Label
 └── Draggable (Node)
  1. Draggable.gd を用意
    上記のコードを res://components/Draggable.gd などに保存します。
  2. ドラッグさせたい Control のシーンを開く
    例: PanelWindowTextureRect など。
  3. 子ノードとして Draggable を追加

    • + ボタン → Node を追加

    • スクリプトに Draggable.gd をアタッチ

    • ノード名を Draggable にしておくとわかりやすいです


    シーン構成は次のようになります:


    WindowPanel (Panel)
    ├── Label
    └── Draggable (Node)

  4. インスペクタでパラメータを確認
    • enabled: true のままで OK
    • drag_handle_path: 空のまま → 親 Control 全体を掴める
    • clamp_to_viewport: true にしておけば画面外に行きません

これでゲームを実行すると、Panel のどこを掴んでもドラッグで移動できるようになります。


例2: タイトルバーだけ掴めるウィンドウ

「ウィンドウのヘッダー部分だけドラッグ可能」にしたい場合です。
ウィンドウの中にボタンやスクロールビューがあるときは、このパターンがおすすめです。

Window (Panel)
 ├── TitleBar (Panel)  ※ここだけドラッグ可能にしたい
 │    └── Label
 ├── Content (VBoxContainer)
 │    ├── Button
 │    └── Button
 └── Draggable (Node)
  1. TitleBar 用の Control を用意
    上記のように、掴む部分を TitleBar (Panel) などの Control で作っておきます。
  2. 親ウィンドウの子に Draggable を追加
    構成図のように Draggable ノードを Window の直下に置きます。
  3. drag_handle_path を設定
    • Draggable ノードを選択
    • インスペクタの drag_handle_path../TitleBar を指定

この構成にすると、

  • TitleBar 上だけドラッグで移動
  • Content 内のボタンは通常通りクリックできる

という挙動になります。
細かい UI の操作感を崩さずに、ドラッグ移動だけを「後付け」できるのがコンポーネントの良いところですね。


例3: エディタ風のドッカブルパネル

エディタっぽい UI で「コンソール」「インスペクタ」などのパネルをドラッグで動かしたい場合も、同じ要領です。

InspectorPanel (PanelContainer)
 ├── Header (HBoxContainer)
 │    ├── Icon (TextureRect)
 │    └── Title (Label)
 ├── Body (VBoxContainer)
 │    └── ...(中身いろいろ)
 └── Draggable (Node)
  1. InspectorPanel シーンの直下に Draggable を追加
  2. drag_handle_path../Header を設定
  3. clamp_to_viewport は好みで ON/OFF

これで、エディタ風 UI でも簡単に「つかんで動かせる」パネルを量産できます。


メリットと応用

この Draggable コンポーネントを使うことで、次のようなメリットがあります。

  • 継承地獄からの解放
    「ドラッグできる Panel」「ドラッグできる TextureRect」などをクラスごとに作る必要がなく、
    どんな Control にも 同じコンポーネントをポン付けできます。
  • シーン構造がシンプル
    既存の UI 構造を壊さず、Draggable を 1 ノード追加するだけ。
    しかも「ドラッグ可能」という責務が Draggable ノードにきれいに分離されます。
  • レベルデザイン / UI デザインが楽
    「このウィンドウは動かしたい」「このダイアログは固定にしたい」といった違いも、
    Draggable ノードの有無や enabled フラグだけで制御可能です。
  • ドラッグ挙動の共通管理
    ドラッグのスナップ、慣性、グリッド吸着などを追加したくなったときも、
    1 箇所(Draggable.gd)を書き換えるだけで、全 UI に反映されます。

改造案:グリッドにスナップさせる

例えば「ドラッグ後の位置を 32px グリッドにスナップさせたい」ときは、
_end_drag() を少し拡張してみましょう。


@export var snap_to_grid: bool = false
@export var grid_size: Vector2 = Vector2(32, 32)

func _end_drag() -> void:
	_is_dragging = false

	if snap_to_grid:
		var pos := _target.global_position
		pos.x = round(pos.x / grid_size.x) * grid_size.x
		pos.y = round(pos.y / grid_size.y) * grid_size.y
		_target.global_position = pos

	if debug_log:
		print("Draggable: drag end. final_pos=", _target.global_position)

こうしておけば、レベルエディタ風 UI やレイアウトツールなどにも、そのまま流用できますね。
コンポーネント指向で作っておくと、こういう「欲しくなったときの一手間」が本当に楽になります。

ぜひ、自分のプロジェクト用にカスタマイズしながら、
「継承より合成」の快適さを体感してみてください。