iOS/iPhone/iPad/watchOS/tvOS/MacOSX/Android プログラミング, Objective-C, Cocoa, Swiftなど
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
}
}
実行結果。