見出し画像

[SwiftUI]月旅行計画

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

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

新規プロジェクト

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

月旅行計画

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

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

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

 Image("Moon")

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

 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") {
           }

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

 HStack {
               Text("Travel Time in Days:")
               
               Text("\(text)")
           }

最後は終了ボタン。

 HStack {
               Spacer()
               
               Button("Quit") {
                   NSApplication.shared.terminate(self)
               }
           }

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

 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 = ""
 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)
           }

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

 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)
   }

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

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

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) {}
}

最終的な内容は以下となる。

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) {}
}

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

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

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

AppDelegate.swift の内容は以下となる。

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
   }
}

実行結果。

アプリ

【関連情報】
SwiftUI 2.0 disable window's zoom button on macOS
入門Carbon
Cocoa.swift
Cocoa勉強会 関東
Cocoa練習帳

この記事が気に入ったらサポートをしてみませんか?