[SwiftUI]月旅行計画

<p>Mac OS Xが発表された際に、開発者を増やすという目的だと思うが、O'Reillyから『入門Carbon』と『入門Cocoa』という書籍が出版された。今回のCocoa練習帳では、『入門Carbon』のサンプル・アプリケーションをSwiftUIで実装することに挑戦した。</p>

<p>Xcodeで、macOSアプリケーションのSwiftUIプロジェクトを生成する。</p>

![新規プロジェクト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12540/bed7d5a8-6894-fb23-516b-0d50e408a6a0.png)

<p>以下のCarbonで実装された初期のMacOS Xのアプリの画面をSwiftUIで実装する。</p>

![月旅行計画.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12540/29d2ab3a-5140-3272-da73-4cea13f74730.png)

<p>UI部品が縦に並んでいるので、ContentView.swift に縦で部品を配置するに記述する。</p>

<pre><code>struct ContentView: View {
    var body: some View {
        VStack {
        }
        .padding(20.0)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}
</code></pre>

<p>月旅行計画の絵を参考にVStack内に部品を配置する。最初は画像。</p>

<pre><code>            Image("Moon")
</code></pre>

<p>次にラジオ・ボタンを配置するが、SwiftUIのPickerだとラベルの位置が自由にならないので、ラベルはテキストで、その下に、ラベルなしのPickerを配置する。</p>

<pre><code>            Text("Mode of Transportation")
 
            Picker(selection: $selected, label: Text("Mode of Transportation")) {
                            Text("Foot").tag(1)
                            Text("Car").tag(2)
                            Text("Commercial Jet").tag(3)
                            Text("Apollo Spacecraft").tag(4)
                        }
                        .pickerStyle(.radioGroup)
                        .labelsHidden()
</code></pre>

<p>次に計算をするボタンを。まだ、中身は空で。</p>

<pre><code>            Button("Compute Travel Time") {
            }
</code></pre>

<p>その下に、計算結果を。Carbonではラベルとテキスト・フィールドだったが、Textとした。</p>

<pre><code>            HStack {
                Text("Travel Time in Days:")
                
                Text("\(text)")
            }
</code></pre>

<p>最後は終了ボタン。</p>

<pre><code>            HStack {
                Spacer()
                
                Button("Quit") {
                    NSApplication.shared.terminate(self)
                }
            }
</code></pre>

<p>計算のコードは簡単なものなので細かくは説明しないが、Pickerの選択結果と計算結果を@Stateの変数に格納している。</p>

<pre><code>    private let kMTPHoursPerDay: Double = 24.0
    private let kMTPDistanceToMoon: Double = 384467.0
    private let kMTPFootMode: Int = 1
    private let kMTPCarMode: Int = 2
    private let kMTPCommercialJetMode: Int = 3
    private let kMTPApolloSpacecraftMode: Int = 4
    @State private var selected = 1
    @State private var text: String = ""
</code></pre>

<pre><code>            Button("Compute Travel Time") {
                var travelTime: Double = 0.0
                switch (selected) {
                case kMTPFootMode:
                    travelTime = (kMTPDistanceToMoon / (4.0 / 0.62)) / kMTPHoursPerDay
                case kMTPCarMode:
                    travelTime = (kMTPDistanceToMoon / (70.0 / 0.62)) / kMTPHoursPerDay
                case kMTPCommercialJetMode:
                    travelTime = (kMTPDistanceToMoon / (600.0 / 0.62)) / kMTPHoursPerDay
                case kMTPApolloSpacecraftMode:
                    travelTime = 4.0
                default:
                    travelTime = 0.0
                }
                text = String(format: "%2.1lf", travelTime)
            }
</code></pre>


<p>このままだとウィンドウサイズが可変となってしまうので、固定サイズになるよう、コンテンツより大きな幅と高さの最小値を設定する。</p>

<pre><code>    var body: some View {
        :
        VStack {
            :
            HStack {
                :
            }
            .padding(20.0)
            .frame(minWidth: 342.0, minHeight: 50)
            
            HStack {
                :
            }
            .padding(20.0)
            .frame(minWidth: 342.0, minHeight: 25)
        }
        .padding(20.0)
        .frame(minWidth: 342.0, minHeight: 512.0)
    }
</code></pre>

<p>VStackの中にHStackを配置した場合、HStackの幅を設定しないと、ウィンドウの横幅が可変となってしまう。</p>

<p>次はウィンドウのCloseとZoomのボタンを無効にする。</p>

<pre><code>struct ContentView: View {
    :
    var body: some View {
        HostingWindowFinder { window in
            guard let w = window else { return }
            w.standardWindowButton(.zoomButton)?.isEnabled = false
            w.standardWindowButton(.closeButton)?.isEnabled = false
            w.styleMask = w.styleMask.subtracting(.resizable)
        }
        VStack {
            :
        }
        .padding(20.0)
        .frame(minWidth: 342.0, minHeight: 512.0)
    }
}
 
struct HostingWindowFinder: NSViewRepresentable {
    var callback: (NSWindow?) -> ()
    func makeNSView(context: Self.Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }
    func updateNSView(_ nsView: NSView, context: Context) {}
}
</code></pre>

<p>最終的な内容は以下となる。</p>

<pre><code>struct ContentView: View {
    private let kMTPHoursPerDay: Double = 24.0
    private let kMTPDistanceToMoon: Double = 384467.0
    private let kMTPFootMode: Int = 1
    private let kMTPCarMode: Int = 2
    private let kMTPCommercialJetMode: Int = 3
    private let kMTPApolloSpacecraftMode: Int = 4
    @State private var selected = 1
    @State private var text: String = ""
    var body: some View {
        HostingWindowFinder { window in
            guard let w = window else { return }
            w.standardWindowButton(.zoomButton)?.isEnabled = false
            w.standardWindowButton(.closeButton)?.isEnabled = false
            w.styleMask = w.styleMask.subtracting(.resizable)
        }
        VStack {
            Image("Moon")
            
            Text("Mode of Transportation")
            
            Picker(selection: $selected, label: Text("Mode of Transportation")) {
                            Text("Foot").tag(1)
                            Text("Car").tag(2)
                            Text("Commercial Jet").tag(3)
                            Text("Apollo Spacecraft").tag(4)
                        }
                        .pickerStyle(.radioGroup)
                        .labelsHidden()
            
            Button("Compute Travel Time") {
                var travelTime: Double = 0.0
                switch (selected) {
                case kMTPFootMode:
                    travelTime = (kMTPDistanceToMoon / (4.0 / 0.62)) / kMTPHoursPerDay
                case kMTPCarMode:
                    travelTime = (kMTPDistanceToMoon / (70.0 / 0.62)) / kMTPHoursPerDay
                case kMTPCommercialJetMode:
                    travelTime = (kMTPDistanceToMoon / (600.0 / 0.62)) / kMTPHoursPerDay
                case kMTPApolloSpacecraftMode:
                    travelTime = 4.0
                default:
                    travelTime = 0.0
                }
                text = String(format: "%2.1lf", travelTime)
            }
            
            HStack {
                Text("Travel Time in Days:")
                
                Text("\(text)")
            }
            .padding(20.0)
            .frame(minWidth: 342.0, minHeight: 50)
            
            HStack {
                Spacer()
                
                Button("Quit") {
                    NSApplication.shared.terminate(self)
                }
            }
            .padding(20.0)
            .frame(minWidth: 342.0, minHeight: 25)
        }
        .padding(20.0)
        .frame(minWidth: 342.0, minHeight: 512.0)
    }
}
 
struct HostingWindowFinder: NSViewRepresentable {
    var callback: (NSWindow?) -> ()
    func makeNSView(context: Self.Context) -> NSView {
        let view = NSView()
        DispatchQueue.main.async { [weak view] in
            self.callback(view?.window)
        }
        return view
    }
    func updateNSView(_ nsView: NSView, context: Context) {}
}
</code></pre>

<p>このままだと、デフォルトで用意されている、このアプリで不要なメニューが表示されるので、NSApplicationDelegateでデフォルトのメニュー項目を非表示とした。</p>

<p>Appクラスにデリゲートを宣言して。</p>

<pre><code>@main
struct MoonTravelPlannerApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
</code></pre>

<p>AppDelegate.swift の内容は以下となる。</p>

<pre><code>import SwiftUI
 
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApplication.shared.mainMenu?.items[1].isHidden = true
        NSApplication.shared.mainMenu?.items[2].isHidden = true
        NSApplication.shared.mainMenu?.items[3].isHidden = true
        NSApplication.shared.mainMenu?.items[4].isHidden = true
        NSApplication.shared.mainMenu?.items[5].isHidden = true
        NSApplication.shared.mainMenu?.items[6].isHidden = true
    }
}
</code></pre>

<p>実行結果。</p>

![アプリ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12540/43474697-2182-7185-12a8-a323e141b8fb.png)

【関連情報】
<a href="https://stackoverflow.com/questions/66020611/swiftui-2-0-disable-windows-zoom-button-on-macos">SwiftUI 2.0 disable window's zoom button on macOS</a>
<a href="https://www.oreilly.co.jp/books/4873110696/">入門Carbon</a>
<a href="https://cocoa-kanto.connpass.com/">Cocoa.swift</a>
<a href="https://www.facebook.com/groups/cocoa.kanto/">Cocoa勉強会 関東</a>
<a href="http://www.bitz.co.jp/weblog/">Cocoa練習帳</a>