【Cocos Creator 3.8】DraggableWindow の実装:アタッチするだけで「UIウィンドウをドラッグ移動」できる汎用スクリプト
このガイドでは、UIパネル(ウィンドウ)の「タイトルバー」をドラッグして、画面内を自由に動かせるようにする汎用コンポーネント DraggableWindow を実装します。任意の UI ノードにこのスクリプトをアタッチし、ドラッグ領域(タイトルバー)と移動させたいウィンドウノードをインスペクタで指定するだけで動作します。
ゲーム内の設定ウィンドウ、ポップアップダイアログ、インベントリ画面など、「ユーザーが好きな位置に動かしたい UI」を簡単に実現できます。
コンポーネントの設計方針
1. 要件整理
- マウスまたはタッチで「タイトルバー部分」をドラッグすると、ウィンドウ全体が移動する。
- ドラッグ中は、ドラッグ開始時の相対位置を維持してスムーズに追従する。
- ウィンドウが画面外へ完全に消えないように、任意で「画面内に制限」できる。
- 外部の GameManager や他スクリプトに依存せず、このコンポーネント単体で完結する。
- エディタ上のインスペクタから、タイトルバーや移動対象、制限範囲などを柔軟に設定できる。
2. 設計アプローチ
- ドラッグ検出には
UITransform+ ノードイベント(Node.EventType.TOUCH_START/MOVE/END/CANCEL)を使用。 - 「ドラッグを受け付けるノード(タイトルバー)」と「実際に動かすノード(ウィンドウ本体)」を別々に指定できるようにする。
- 指定されていない場合は、自ノードをウィンドウ本体として扱うなど、最低限のフォールバックを用意する。
- 画面内制限は
CanvasのUITransformを取得して、その矩形内に収まるように位置をクランプする。 - マルチプラットフォーム対応のため、マウス・タッチの両方を
TOUCH_*イベントで一括処理。
3. インスペクタで設定可能なプロパティ
以下のプロパティを用意します。
dragHandle(Node)- ドラッグ操作を受け付けるノード(タイトルバー)。
- 未指定の場合は、自ノード自身をドラッグハンドルとして扱う。
targetWindow(Node)- 実際に移動させるウィンドウノード。
- 未指定の場合は、自ノード自身を移動対象とする。
- 通常は「パネル全体のルートノード」を指定する。
limitToCanvas(boolean)- true にすると、ウィンドウが Canvas の矩形範囲外に出ないようにする。
- false の場合、制限なしでどこまでも移動できる。
canvasNode(Node)limitToCanvasが true の場合に使用する、基準となる Canvas ノード。- 未指定の場合は、起動時に親階層から
Canvasを自動探索する。 - 自動取得に失敗した場合は警告を出し、制限なしで動作する。
useLocalSpace(boolean)- true:
targetWindowのローカル座標系で移動(通常の UI レイアウト向け)。 - false: ワールド座標系を直接操作する(特殊なケース向け)。
- true:
debugLog(boolean)- ドラッグ開始・終了時にログを出すかどうか。
- デバッグ時にのみ有効にしておくと便利。
TypeScriptコードの実装
import { _decorator, Component, Node, EventTouch, Vec3, UITransform, Canvas, view, math } from 'cc';
const { ccclass, property } = _decorator;
/**
* DraggableWindow
* 任意の UI ウィンドウをドラッグで移動可能にするコンポーネント。
* - dragHandle: ドラッグ操作を受け付けるタイトルバーなどのノード
* - targetWindow: 実際に移動させるウィンドウノード
* - limitToCanvas: Canvas の矩形範囲内に移動を制限するかどうか
* - canvasNode: 制限の基準となる Canvas ノード(未設定時は自動検出)
*/
@ccclass('DraggableWindow')
export class DraggableWindow extends Component {
@property({
type: Node,
tooltip: 'ドラッグ操作を受け付けるノード(タイトルバーなど)。\n未指定の場合、このコンポーネントが付いているノード自身がドラッグハンドルになります。'
})
public dragHandle: Node | null = null;
@property({
type: Node,
tooltip: '実際に移動させるウィンドウノード。\n未指定の場合、このコンポーネントが付いているノード自身を移動します。'
})
public targetWindow: Node | null = null;
@property({
tooltip: 'true の場合、ウィンドウの移動を Canvas の矩形範囲内に制限します。'
})
public limitToCanvas: boolean = true;
@property({
type: Node,
tooltip: '移動制限の基準となる Canvas ノード。\n未指定の場合は、親階層から自動的に Canvas コンポーネントを探索します。'
})
public canvasNode: Node | null = null;
@property({
tooltip: 'true の場合、targetWindow のローカル座標系で移動します。\n通常の UI では true 推奨です。'
})
public useLocalSpace: boolean = true;
@property({
tooltip: 'ドラッグ開始・終了時にログを出力するかどうか。'
})
public debugLog: boolean = false;
// 内部状態
private _isDragging: boolean = false;
private _dragStartWindowPos: Vec3 = new Vec3();
private _dragStartTouchPos: Vec3 = new Vec3();
private _cachedCanvasTransform: UITransform | null = null;
private _windowTransform: UITransform | null = null;
onLoad() {
// dragHandle / targetWindow のデフォルト設定
if (!this.dragHandle) {
this.dragHandle = this.node;
}
if (!this.targetWindow) {
this.targetWindow = this.node;
}
if (!this.dragHandle) {
console.error('[DraggableWindow] dragHandle が設定されていません。コンポーネントを削除するか、ドラッグ対象ノードを設定してください。', this.node);
return;
}
if (!this.targetWindow) {
console.error('[DraggableWindow] targetWindow が設定されていません。コンポーネントを削除するか、移動対象ノードを設定してください。', this.node);
return;
}
// 必要な UITransform を取得
this._windowTransform = this.targetWindow.getComponent(UITransform);
if (!this._windowTransform) {
console.warn('[DraggableWindow] targetWindow に UITransform コンポーネントが見つかりません。UI ノードにアタッチしているか確認してください。', this.targetWindow);
}
// Canvas 制限用の UITransform をキャッシュ
this._initCanvasTransform();
// ドラッグイベント登録
this._registerDragEvents();
}
onDestroy() {
this._unregisterDragEvents();
}
/**
* Canvas ノードの UITransform を初期化
*/
private _initCanvasTransform() {
if (!this.limitToCanvas) {
this._cachedCanvasTransform = null;
return;
}
let canvasNode: Node | null = this.canvasNode;
if (!canvasNode) {
// 親階層から Canvas を探索
let current: Node | null = this.node;
while (current) {
const canvasComp = current.getComponent(Canvas);
if (canvasComp) {
canvasNode = current;
break;
}
current = current.parent;
}
if (!canvasNode) {
console.warn('[DraggableWindow] Canvas ノードが見つかりませんでした。移動制限は無効になります。Canvas ノードを明示的に設定するか、limitToCanvas を false にしてください。', this.node);
this._cachedCanvasTransform = null;
return;
}
}
const uiTrans = canvasNode.getComponent(UITransform);
if (!uiTrans) {
console.warn('[DraggableWindow] 指定された Canvas ノードに UITransform が見つかりません。移動制限は無効になります。', canvasNode);
this._cachedCanvasTransform = null;
return;
}
this._cachedCanvasTransform = uiTrans;
}
/**
* ドラッグ用のタッチイベントを登録
*/
private _registerDragEvents() {
if (!this.dragHandle) {
return;
}
this.dragHandle.on(Node.EventType.TOUCH_START, this._onTouchStart, this);
this.dragHandle.on(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
this.dragHandle.on(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this.dragHandle.on(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
}
/**
* ドラッグ用のタッチイベントを解除
*/
private _unregisterDragEvents() {
if (!this.dragHandle) {
return;
}
this.dragHandle.off(Node.EventType.TOUCH_START, this._onTouchStart, this);
this.dragHandle.off(Node.EventType.TOUCH_MOVE, this._onTouchMove, this);
this.dragHandle.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this.dragHandle.off(Node.EventType.TOUCH_CANCEL, this._onTouchEnd, this);
}
/**
* タッチ開始時:ドラッグ開始
*/
private _onTouchStart(event: EventTouch) {
if (!this.targetWindow) {
return;
}
this._isDragging = true;
// ドラッグ開始時のウィンドウ位置を保存
this._dragStartWindowPos = this.targetWindow.position.clone();
// タッチ位置を Canvas(もしくはウィンドウ親)座標系に変換して保存
const touchLocation = event.getUILocation();
this._dragStartTouchPos.set(touchLocation.x, touchLocation.y, 0);
if (this.debugLog) {
console.log('[DraggableWindow] Drag start', {
windowPos: this._dragStartWindowPos,
touchPos: this._dragStartTouchPos
});
}
event.propagationStopped = true;
}
/**
* タッチ移動中:ウィンドウを追従させる
*/
private _onTouchMove(event: EventTouch) {
if (!this._isDragging || !this.targetWindow) {
return;
}
const touchLocation = event.getUILocation();
const currentTouchPos = new Vec3(touchLocation.x, touchLocation.y, 0);
// タッチの移動量(UI座標系)
const delta = currentTouchPos.subtract(this._dragStartTouchPos);
// 新しいウィンドウ位置
let newPos = this._dragStartWindowPos.clone();
newPos.add3f(delta.x, delta.y, 0);
// Canvas 内に制限する場合はここでクランプ
if (this.limitToCanvas && this._cachedCanvasTransform && this._windowTransform) {
newPos = this._clampToCanvas(newPos, this._cachedCanvasTransform, this._windowTransform);
}
// 座標系に応じて位置を適用
if (this.useLocalSpace) {
this.targetWindow.setPosition(newPos);
} else {
// ワールド座標で設定する場合
const worldPos = new Vec3();
this.targetWindow.parent?.getComponent(UITransform)?.convertToWorldSpaceAR(newPos, worldPos);
this.targetWindow.setWorldPosition(worldPos);
}
event.propagationStopped = true;
}
/**
* タッチ終了/キャンセル時:ドラッグ終了
*/
private _onTouchEnd(event: EventTouch) {
if (!this._isDragging) {
return;
}
this._isDragging = false;
if (this.debugLog) {
console.log('[DraggableWindow] Drag end. Final window position:', this.targetWindow?.position);
}
event.propagationStopped = true;
}
/**
* ウィンドウの中心位置が Canvas の矩形内に収まるようにクランプする。
* @param windowPos ウィンドウのローカル位置
* @param canvasTrans Canvas の UITransform
* @param windowTrans ウィンドウの UITransform
*/
private _clampToCanvas(windowPos: Vec3, canvasTrans: UITransform, windowTrans: UITransform): Vec3 {
const result = windowPos.clone();
// Canvas のサイズ
const canvasWidth = canvasTrans.width;
const canvasHeight = canvasTrans.height;
// ウィンドウのサイズ
const winWidth = windowTrans.width;
const winHeight = windowTrans.height;
// Canvas の中心を (0,0) とした場合の半分サイズ
const halfCanvasW = canvasWidth * 0.5;
const halfCanvasH = canvasHeight * 0.5;
// ウィンドウの半分サイズ
const halfWinW = winWidth * 0.5;
const halfWinH = winHeight * 0.5;
// ウィンドウの中心が Canvas 内に収まるようにクランプ
const minX = -halfCanvasW + halfWinW;
const maxX = halfCanvasW - halfWinW;
const minY = -halfCanvasH + halfWinH;
const maxY = halfCanvasH - halfWinH;
result.x = math.clamp(result.x, minX, maxX);
result.y = math.clamp(result.y, minY, maxY);
return result;
}
}
コードのポイント解説
onLoad()dragHandle,targetWindowが未設定なら自ノードをデフォルトとして採用。targetWindowにUITransformが無い場合は警告ログを出す(UI ノードにアタッチすべきであるため)。Canvasの自動検出と、制限用UITransformのキャッシュを行う。- ドラッグ用タッチイベント(
TOUCH_START/MOVE/END/CANCEL)を登録する。
_onTouchStart()- ドラッグ開始フラグを立てる。
- ドラッグ開始時のウィンドウ位置(
_dragStartWindowPos)とタッチ位置(_dragStartTouchPos)を保存。 - 以後は「開始位置 + タッチ移動量」でウィンドウ位置を計算する。
_onTouchMove()- 現在のタッチ位置(UI座標系)から移動量
deltaを計算。 newPos = _dragStartWindowPos + deltaで新しい位置を求める。limitToCanvasが true かつ Canvas 情報がある場合は、_clampToCanvas()で Canvas 内に収まるようにクランプ。useLocalSpaceに応じてローカル or ワールド座標で位置を適用。
- 現在のタッチ位置(UI座標系)から移動量
_onTouchEnd()- ドラッグフラグを解除し、必要に応じてログを出力。
_clampToCanvas()- Canvas とウィンドウのサイズから、ウィンドウの中心が Canvas 内に収まるように X/Y をクランプ。
- これにより、ウィンドウが完全に画面外へ消えてしまうことを防ぐ。
使用手順と動作確認
1. スクリプトファイルの作成
- Assets パネルで右クリック → Create → TypeScript を選択します。
- ファイル名を
DraggableWindow.tsに変更します。 - 作成された
DraggableWindow.tsをダブルクリックして開き、内容をすべて削除して、前述のコードをそのまま貼り付けて保存します。
2. テスト用 UI ウィンドウの作成
- Hierarchy パネルで右クリック → Create → UI → Canvas を選択し、Canvas が無い場合は作成します。
- Canvas の子として、右クリック → Create → UI → Sprite を選択し、ウィンドウ本体となるノードを作成します。
- 名前を
TestWindowなどに変更します。 - Inspector の
UITransformで Width: 400, Height: 300 など、適当なサイズに設定します。 - Sprite の Color を少しグレーにするなどして、ウィンドウ本体を視認しやすくしておきます。
- 名前を
TestWindowの子として、右クリック → Create → UI → Sprite を選択し、タイトルバー用ノードを作成します。- 名前を
TitleBarなどに変更します。 UITransformで Width: 400(親と同じ), Height: 60 などに設定します。- Anchor を (0.5, 1.0)、Position を (0, 150) に設定し、ウィンドウ上部に来るように配置します。
- Color を少し濃い色にして、ドラッグするバーだと分かるようにすると良いです。
- 名前を
3. DraggableWindow コンポーネントのアタッチ
- Hierarchy で
TestWindowノードを選択します。 - Inspector の下部で Add Component → Custom → DraggableWindow を選択し、コンポーネントを追加します。
- Inspector 上で
DraggableWindowの各プロパティを設定します。- Drag Handle:
- Hierarchy から
TitleBarノードをドラッグ&ドロップして設定します。 - タイトルバー部分だけでドラッグできるようになります。
- Hierarchy から
- Target Window:
- 通常は自動で
TestWindow(自身)が入っているはずですが、入っていなければTestWindowを指定します。
- 通常は自動で
- Limit To Canvas:
- ウィンドウが画面外に消えないようにしたい場合は ON(チェック) にします。
- Canvas Node:
- 空欄のままでも、親階層から自動的に Canvas を探します。
- 自動検出がうまくいかない構成の場合は、Hierarchy の
Canvasノードをドラッグ&ドロップして明示的に指定してください。
- Use Local Space:
- 通常の UI では ON(true) のままで問題ありません。
- Debug Log:
- 挙動を確認したい場合は ON にすると、ドラッグ開始・終了時に Console にログが出力されます。
- Drag Handle:
4. 実行して動作を確認する
- エディタ右上の Play ボタンを押してプレビューを開始します。
- ゲーム画面上で、
TitleBar部分をマウスでドラッグしてみます。- ウィンドウ全体(
TestWindow)が、ドラッグに追従して移動すれば成功です。 Limit To Canvasを ON にしている場合、ウィンドウが画面外に完全には消えないことを確認してください。
- ウィンドウ全体(
- スマホビルドやシミュレータでタッチ操作を行っても、同様にドラッグできるはずです。
5. よくあるつまずきポイント
- ドラッグしても動かない場合
DraggableWindowが正しいノードにアタッチされているか確認。Drag Handleプロパティに、実際にドラッグするノード(例:TitleBar)が設定されているか確認。- Canvas 配下の UI ノードになっているか(
UITransformが付いているか)確認。
- ウィンドウが画面外に消えて戻せなくなる場合
Limit To Canvasが ON になっているか確認。- Canvas ノードが正しく設定されているか(または自動検出できているか)確認。
まとめ
DraggableWindow コンポーネントを使うことで、
- 任意の UI ウィンドウを「タイトルバーをドラッグして移動」できるようにする機能を、
- 外部スクリプトやシングルトンに一切依存せず、
- Inspector でノードを指定するだけで、どのシーン・どのウィンドウにも簡単に再利用
できるようになります。
例えば、
- 設定画面、インベントリ、チャットウィンドウなど、複数の UI パネルに
DraggableWindowをアタッチする。 - 一部のウィンドウは画面外に出てもよいので
Limit To Canvas = falseにする。 - デザイナーが Inspector から
Drag Handleに別の装飾ノードを指定して、ドラッグ領域を自由に変更する。
といった形で、スクリプト単体で柔軟な UI ウィンドウ操作を実現できます。プロジェクトの共通コンポーネントとして保存しておけば、今後どのゲームでも「アタッチするだけ」でドラッグ可能なウィンドウを素早く構築できるようになるはずです。




