Swift UIでウィンドウサイズをドラッグで変更する

Mac向けのアプリをSwift UIで作るとき、通常、メインウィンドウとなるViewはWindowGroupで囲みます。

そのため、カーソルをウィンドウ端に移動させれば、カーソルアイコンが矢印やハンドに自動で切り替わり、ドラッグでウィンドウサイズを自由に変更できる機能を最初から利用できます。

@main
struct WindowDragApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

しかし、私が個人で開発しているMacのメニューバーに常駐するノートアプリMizuameNSPopoverで作成されているため、デフォルトではドラッグでウィンドウサイズを自由に変更できません。もしかしたらMenuBarExtraを利用すればデフォルトで変更可能なのかもしれませんが、試したことがないのでわかりません。

今回は、ドラッグでウィンドウズサイズを変更する機能を自前で実装しました。

開発環境

仕様

標準的なリサイズ機能を実装します。

ウィンドウ枠の横をドラッグすると水平方向にリサイズでき、ウィンドウ枠の下をドラッグすると垂直方向にリサイズでき、ウィンドウ枠の右下角であれば、水平・垂直の両方向へリサイズできます。
ただし、下図のように右と下と右下角以外のウィンドウ枠では、リサイズできないようにします。そして、リサイズ中に別途定めたウィンドウの上限サイズと下限サイズを超えないようにします。

//    # -> 水平方向にのみリサイズできる
//    + -> 垂直方向にのみリサイズできる
//    @ -> 水平方向と垂直方向の両方にリサイズできる
//
//    *---------------------*
//    |                     #
//    |                     #
//    |       ノート本体      #
//    |                     #
//    |                     #
//    *+++++++++++++++++++++@

先に完成したものをお見せすると、以下のように動作します。

ウィンドウのリサイズ

実装

今回はドラッグ可能なエリアを限定するので、ドラッグ可能となるエリアに四角(Rectangle())を配置して、各々にgesturemodifierを設定して、それにDragGestureを渡します。

developer.apple.com

ドラッグ可能なエリアはウィンドウ枠の右と下と右下角の3カ所です。
ウィンドウ枠の右には縦に長い長方形、ウィンドウ枠の下には横に長い長方形、ウィンドウ枠の右下角には正方形を配置します。

3カ所とも実装はほとんど同じになるので、ここではウィンドウ枠の右の実装を抜粋して載せます。全体の実装は下記になります。

github.com

//ドラッグ中の変化量を保持する変数
@GestureState private var dragState: CGSize = .zero


ZStack {
    // ZStackはアプリ全体の外枠であり、ウィンドウそのものである
    // アプリのUIのあれこれが実装してある
}
//ウィンドウのサイズ
//self.dragStateはドラッグ中は何かしらの変化量が入り、ドラッグしていない場合は .zero が入る
//これにより、ドラッグ中のウィンドウがリサイズするアニメーションを表現する
.frame(width: CGFloat(self.width) + self.dragState.width, height: CGFloat(self.height) + self.dragState.height)



// ウィンドウ枠の右部分なので縦長の長方形にする
//self.heightは現在のウィンドウ全体の高さ
//self.dragStateはドラッグによる変化量
Rectangle()
    .frame(width: 10, height: CGFloat(self.height) + self.dragState.height - 20)
    .gesture(
        // coordinateSpace:は座標系であり、デフォルトは.localである
        // localはmodifierしているUI、globalがアプリ全体を座標系とする
        DragGesture(minimumDistance: 1, coordinateSpace: .global)
            .updating($dragState) { gestureValue, gestureState, gestureTransaction in

                // updatingはドラッグ中に発生するイベントである
                // updatingはdragStateにドラッグ中の変化量(gestureState)を保存する
                                    
                // 特定の方向のサイズだけを更新する
                // ここでは水平方向だけを更新する
                // gestureValueには現在ドラッグ中の座標が入っている
                var dragged = gestureValue.translation

                // リサイズ後のウィンドウサイズを計算する
                let willUpdateWithWidth: Int = self.width + Int(dragged.width)

                // リサイズ後のウィンドウサイズが上限や下限を超えているか調べる
                if willUpdateWithWidth > MAX_WIDTH {
                    dragged.width = CGFloat(MAX_WIDTH - self.width)
                }
                                   
                if willUpdateWithWidth < MIM_WIDTH {
                    dragged.width = CGFloat(MIM_WIDTH - self.height)
                }

                // ここでは水平方向(幅)のリサイズを行い、高さのリサイズを行わない
                // なので、高さの変化量は常に0とする
                dragged.height = CGFloat(0)
                dragged.width = dragged.width
                gestureState = dragged// self.dragStateの更新
            }
            .onEnded { endedState in

                // onEndedはドラッグが終了したときに発生するイベントである
                // ここではドラッグによる最終的な変化量をウィンドウサイズに加算して、ウィンドウサイズを固定する

                // リサイズ後のウィンドウサイズを計算する
                var draggedWidth = endedState.translation.width

                let willUpdateWithWidth: Int = self.width + Int(draggedWidth)
                                    
                // リサイズ後のウィンドウサイズが上限や下限を超えているか調べる
                if willUpdateWithWidth > SettingKeys.StickyNote().maxWidth.intValue {
                    draggedWidth = CGFloat(SettingKeys.StickyNote().maxWidth.intValue - self.width)
                }
                                    
                if willUpdateWithWidth < SettingKeys.StickyNote().minWidth.intValue {
                    draggedWidth = CGFloat(SettingKeys.StickyNote().minWidth.intValue - self.width)
                }

                // 最終的な変化量をウィンドウサイズに加算して、ウィンドウサイズを固定する
                self.width += Int(draggedWidth)

            }
    )

ポイント

今夏の場合は、DragGesture(minimumDistance:, coordinateSpace:)coordinateSpace.globalにすることです。
デフォルトの.localのままだと、ドラッグしたときの変化量が単調増加または単調減少とならず、非連続な変化量となり、ウィンドウのリサイズ中のアニメーションがスムーズになりません。

.globalはアプリ全体を座標系とするのに対し、.localだとgestureでmodifierしているUIオブジェクトを座標系とします。

もし、今回の場合で.localを採用すると、ドラッグ可能エリアとするRectangle()gestureをmodifierしているので、ドラッグによりアプリ全体(ウィンドウサイズ)が変化すると、それに合わせてドラッグ可能エリアの座標も変化します。すると、ドラッグ中に座標のズレが発生してしまい、ウィンドウリサイズ中のアニメーションがぎこちなくなります。

.local.globalのどちらを採用するかは、ドラッグをどこで行いたいかによるので、ケース・バイ・ケースとなります。

最後に

メニューバーからアクセスするノートアプリMizuameはMITライセンスで公開しています

github.com

AppStoreにも公開しています。
広告は好きではないので付いてません。

2026-01-13 Mizuameは公開を停止しました。
https//apps.apple.com/jp/app/mizuame/id6458394832?mt=12
ソースコードはMITライセンスで公開を続けています。

github.com