「SwiftUI」の版間の差分
ナビゲーションに移動
検索に移動
(→画像) |
|||
(同じ利用者による、間の136版が非表示) | |||
1行目: | 1行目: | ||
− | | [[Swift]] | [[Mac]] | | + | | [[Swift]] | [[Mac]] | [[Xcode]] | [[Swift Sample]] | [[Cocoa]] | [[Xamarin.Mac]] | |
+ | |||
+ | {{amazon|B082SMJC7V}} | ||
+ | |||
==SwiftUI== | ==SwiftUI== | ||
− | *https://developer.apple.com/jp/xcode/swiftui/ | + | *[https://developer.apple.com/jp/xcode/swiftui/ SwiftUI] |
+ | *[https://developer.apple.com/documentation/swiftui/ SwiftUI Documents] | ||
+ | *[https://www.raywenderlich.com/macos macos tutorials] | ||
*1セットのツールとAPIを使用するだけで、あらゆるAppleデバイス向けのユーザーインターフェイスを構築 | *1セットのツールとAPIを使用するだけで、あらゆるAppleデバイス向けのユーザーインターフェイスを構築 | ||
*宣言型シンタックスを使 | *宣言型シンタックスを使 | ||
18行目: | 23行目: | ||
===Swift UI チュートリアルをやってみる=== | ===Swift UI チュートリアルをやってみる=== | ||
+ | ---- | ||
+ | *[https://www.typea.info/blog/index.php/2020/12/09/swiftui_tutorial_youtube_memo/ プロジェクト作成〜TextViewのカスタマイズ] | ||
+ | *[[Xcode]] | ||
+ | *resume -> プレビュー | ||
+ | *command + click -> Action List | ||
+ | *右上の + ボタンでコントロール追加 | ||
+ | |||
+ | ====[[Xcode]] ナビゲーター==== | ||
+ | ---- | ||
+ | [[File:xcode_navgator_icons.png|400px]] | ||
+ | 左から | ||
+ | #プロジェクトナビゲーター | ||
+ | #ソースコントロールナビゲーター | ||
+ | #シンボルナビゲーター | ||
+ | #検索ナビゲーター | ||
+ | #イシューナビゲーター | ||
+ | #テストナビゲーター | ||
+ | #デバッグナビゲーター | ||
+ | #ブレークポイントナビゲーター | ||
+ | #レポートナビゲーター | ||
+ | |||
====[https://www.typea.info/blog/index.php/2020/12/09/swiftui_tutorial_youtube_memo/ プロジェクト作成〜TextViewのカスタマイズ]==== | ====[https://www.typea.info/blog/index.php/2020/12/09/swiftui_tutorial_youtube_memo/ プロジェクト作成〜TextViewのカスタマイズ]==== | ||
====[https://www.typea.info/blog/index.php/2020/12/19/swiftui_tutorial_custom_image_view/ Custom Image Viewの作成]==== | ====[https://www.typea.info/blog/index.php/2020/12/19/swiftui_tutorial_custom_image_view/ Custom Image Viewの作成]==== | ||
====[https://www.typea.info/blog/index.php/2020/12/06/xcode_macos_proguramming/ Xcodeを使ってmacOS プログラミングとplaygroundの作成]==== | ====[https://www.typea.info/blog/index.php/2020/12/06/xcode_macos_proguramming/ Xcodeを使ってmacOS プログラミングとplaygroundの作成]==== | ||
====[https://www.typea.info/blog/index.php/2021/01/23/swiftui_tutorial_list_navigation/ Listとナビゲーションとプレビュー]==== | ====[https://www.typea.info/blog/index.php/2021/01/23/swiftui_tutorial_list_navigation/ Listとナビゲーションとプレビュー]==== | ||
− | === | + | |
− | ====画像==== | + | ==レイアウト== |
+ | ===[https://d1v1b.com/swiftui/alignment Alignment]=== | ||
+ | ---- | ||
+ | *[https://d1v1b.com/swiftui/alignment Alignment] | ||
+ | |||
+ | ===[https://t32k.me/mol/log/margin-padding-swiftui/ 余白の取り方]=== | ||
+ | ---- | ||
+ | *[https://t32k.me/mol/log/margin-padding-swiftui/ 余白の取り方] | ||
+ | *記述箇所によって、表示が変わる | ||
+ | |||
+ | *backgroundにpaddingを指定する(cssのmargin的な効果) | ||
+ | [[File:swiftui_padding2.png|300px]] | ||
+ | <pre> | ||
+ | Text(host.host) | ||
+ | .background(Color.green) | ||
+ | .padding() | ||
+ | </pre> | ||
+ | *Textにpaddingを指定することになる(cssのpadding的効果) | ||
+ | [[File:swiftui_padding1.png|300px]] | ||
+ | <pre> | ||
+ | Text(host.host) | ||
+ | .padding() | ||
+ | .background(Color.green) | ||
+ | </pre> | ||
+ | |||
+ | ===ボタンサイズ=== | ||
+ | ---- | ||
+ | *ボタンのサイズを内容にフィットさせたい | ||
+ | [[File:swiftui_button_size1.png|300px]] | ||
+ | <pre> | ||
+ | Button(action: {}) { | ||
+ | VStack{ | ||
+ | : | ||
+ | } | ||
+ | .padding() | ||
+ | .border(Color.blue, width: 3) | ||
+ | } | ||
+ | </pre> | ||
+ | *.buttonStyle(PlainButtonStyle()) を指定 | ||
+ | [[File:swiftui_button_size2.png|300px]] | ||
+ | <pre> | ||
+ | Button(action: {}) { | ||
+ | VStack{ | ||
+ | : | ||
+ | } | ||
+ | .padding() | ||
+ | .border(Color.blue, width: 3) | ||
+ | }.buttonStyle(PlainButtonStyle()) | ||
+ | </pre> | ||
+ | ===親Viewのサイズ情報を取得する=== | ||
+ | ---- | ||
+ | *https://qiita.com/masa7351/items/0567969f93cc88d714ac | ||
+ | *https://www.hackingwithswift.com/quick-start/swiftui/how-to-make-two-views-the-same-width-or-height | ||
+ | [[File:swiftui_grid_layout1.png|200px]] | ||
+ | [[File:swiftui_grid_layout2.png|400px]] | ||
+ | <pre> | ||
+ | struct HostView : View { | ||
+ | var host: WoL.Host | ||
+ | var body: some View { | ||
+ | GeometryReader { geo in | ||
+ | HStack() { | ||
+ | Text(host.host) | ||
+ | .padding() | ||
+ | .frame(width: geo.size.width * 0.33 , alignment: .leading) | ||
+ | .frame(maxHeight: .infinity) | ||
+ | .background(Color.red) | ||
+ | Text(host.ip) | ||
+ | .padding() | ||
+ | .frame(width: geo.size.width * 0.33 , alignment: .leading) | ||
+ | .frame(maxHeight: .infinity) | ||
+ | .background(Color.green) | ||
+ | Text(host.macaddr) | ||
+ | .padding() | ||
+ | .frame(width: geo.size.width * 0.33 , alignment: .leading) | ||
+ | .frame(maxHeight: .infinity) | ||
+ | .background(Color.yellow) | ||
+ | }.fixedSize(horizontal: false, vertical: true) | ||
+ | |||
+ | }.frame(height: 60) | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===影をつける=== | ||
+ | --- | ||
+ | *shadowを設定すると、内部のコンポーネント全てに影がつく、compositingGroup を指定することで、背景だけに影をつけることができる | ||
+ | <pre> | ||
+ | : | ||
+ | .background() | ||
+ | .compositingGroup() | ||
+ | .shadow(radius: 10) | ||
+ | </pre> | ||
+ | ===Card View=== | ||
+ | ---- | ||
+ | *[https://www.hackingwithswift.com/books/ios-swiftui/designing-a-single-card-view Card View] | ||
+ | *LazyGrid | ||
+ | [[File:swiftui_card_layout1.png|400px]] | ||
+ | [[File:swiftui_card_layout2.png|200px]] | ||
+ | <pre> | ||
+ | let columns = | ||
+ | [GridItem(.adaptive(minimum: 250, maximum: 800))] | ||
+ | ScrollView { | ||
+ | LazyVGrid(columns: columns, spacing:10) { | ||
+ | ForEach(hosts.hosts, id: \.macaddr) { host in | ||
+ | HostView(host:host) | ||
+ | } | ||
+ | } .padding(20) | ||
+ | </pre> | ||
+ | |||
+ | ===Toolbar=== | ||
+ | ---- | ||
+ | [[File:swiftui_toolbar.png|300lx]] | ||
+ | <pre> | ||
+ | ScrollView { | ||
+ | LazyVGrid(columns: columns) { | ||
+ | ForEach(hosts.hosts, id: \.ip) { host in | ||
+ | HostView(host:host) | ||
+ | } | ||
+ | } .padding(20) | ||
+ | }.toolbar { | ||
+ | ToolbarItem(placement: .automatic) { | ||
+ | Button("arp -a") { | ||
+ | WoLService().arp(hosts:self.hosts) | ||
+ | } | ||
+ | } | ||
+ | ToolbarItem(placement: .automatic) { | ||
+ | Button("load") { | ||
+ | WoLService().load(hosts:self.hosts) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ==データ== | ||
+ | === データ変更に応じて画面に反映させる === | ||
+ | * 画面の構造体の中でデータを保持しているクラスのインスタンスを格納するプロパティに、@ObservedObjectプロパティラッパーを付与する | ||
+ | * データを保持しているクラスを、@ObservableObjectプロトコルに準拠させる | ||
+ | * クラスの中で変更を反映させる値を保持しているプロパティに、@Publishedプロパティラッパーを付与する | ||
+ | |||
+ | === UserDefaults=== | ||
+ | ---- | ||
+ | |||
+ | ===ドキュメントパス=== | ||
+ | ---- | ||
+ | <pre> | ||
+ | let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) | ||
+ | print("Document path: \(paths)") | ||
+ | </pre> | ||
+ | *出力 | ||
+ | <pre> | ||
+ | Document path: [file:///Users/hirotoyagi/Library/Containers/info.typea.WoL/Data/Documents/] | ||
+ | </pre> | ||
+ | ====ファイル格納先==== | ||
+ | ---- | ||
+ | *[https://qiita.com/am10/items/3b2eb3d9f6c6955455b6 ファイル格納先] | ||
+ | ===View間データ受け渡し=== | ||
+ | ---- | ||
+ | SwiftUIでは親ビューと子ビュー間で値を渡す方法は、下記の3つが挙げられます。 | ||
+ | |||
+ | #Environment | ||
+ | ##Viewが持つ環境変数。独自の環境変数を定義することができ、それを利用して親ビューから任意の値を渡すことが可能 | ||
+ | #EnvironmentObjects | ||
+ | ##他の2つに比べて一般的な方法。利用するためには独自のクラスを定義 | ||
+ | #Preferences | ||
+ | ##Preferenceは子から親へ伝達させる方法 | ||
+ | |||
+ | |||
+ | |||
+ | |||
+ | *https://qiita.com/noby111/items/26405bd89075c841029a | ||
+ | ====ObservableObject を経由して親子ViewでAlertの表示フラグを共有==== | ||
+ | [[File:swiftui_share_object.png|300px]] | ||
+ | *ObservableObjectを共有して、変更をSubscribeする | ||
+ | <pre> | ||
+ | struct HostListView: View { | ||
+ | @ObservedObject var hosts = HostList() | ||
+ | @ObservedObject var param = Param() | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | let columns = | ||
+ | [GridItem(.adaptive(minimum: 250, maximum: 800))] | ||
+ | ScrollView { | ||
+ | LazyVGrid(columns: columns, spacing:10) { | ||
+ | ForEach(hosts.hosts, id: \.id) { host in | ||
+ | HostView(host:host).environmentObject(param) | ||
+ | } | ||
+ | } .padding(20) | ||
+ | }.alert(isPresented: $param.isSaveAlert, content: { | ||
+ | Alert(title: Text("Title"),message: Text("Messge"), | ||
+ | primaryButton: .default(Text("OK"), action: {}), | ||
+ | secondaryButton: .cancel(Text("Cancel"),action: {})) | ||
+ | }) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | struct HostView : View { | ||
+ | @ObservedObject var host: WoL.Host | ||
+ | @EnvironmentObject var param: Param | ||
+ | |||
+ | var body: some View { | ||
+ | VStack{ | ||
+ | HStack { | ||
+ | Button(action: { | ||
+ | self.param.isSaveAlert = true | ||
+ | }) { | ||
+ | Image(systemName: "square.and.arrow.down") | ||
+ | }.help(Text("save")) | ||
+ | } | ||
+ | : | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | class Param : ObservableObject { | ||
+ | @Published var isSaveAlert: Bool = false | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ====@Binding を経由して親子ViewでAlertの表示フラグを共有==== | ||
+ | [[File:swiftui_share_object.png|300px]] | ||
+ | *@Binding でView間で変数を共有 | ||
+ | *呼び出し側は@State | ||
+ | <pre> | ||
+ | struct HostListView: View { | ||
+ | @ObservedObject var hosts = HostList() | ||
+ | @State var isSaveAlert = false | ||
+ | |||
+ | var body: some View { | ||
+ | VStack { | ||
+ | let columns = | ||
+ | [GridItem(.adaptive(minimum: 250, maximum: 800))] | ||
+ | ScrollView { | ||
+ | LazyVGrid(columns: columns, spacing:10) { | ||
+ | ForEach(hosts.hosts, id: \.id) { host in | ||
+ | HostView(host:host, isSaveAlert: $isSaveAlert, alertMessage: $alertMessage) | ||
+ | } | ||
+ | } .padding(20) | ||
+ | }.alert(isPresented: $isSaveAlert, content: { | ||
+ | Alert(title: Text("Title"),message: Text(alertMessage), | ||
+ | primaryButton: .default(Text("OK"), action: {}), | ||
+ | secondaryButton: .cancel(Text("Cancel"),action: {})) | ||
+ | }) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | struct HostView : View { | ||
+ | @ObservedObject var host: WoL.Host | ||
+ | @Binding var isSaveAlert: Bool | ||
+ | |||
+ | var body: some View { | ||
+ | VStack{ | ||
+ | HStack { | ||
+ | Button(action: { | ||
+ | self.isSaveAlert = true | ||
+ | }) { | ||
+ | Image(systemName: "square.and.arrow.down") | ||
+ | }.help(Text("save")) | ||
+ | : | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | =====@Binding使用時のPreview===== | ||
+ | *@Stateかつ、staticで宣言 | ||
+ | *$で変数を渡す | ||
+ | <pre> | ||
+ | struct HostView_Previews: PreviewProvider { | ||
+ | @State static var isAlert = true | ||
+ | |||
+ | static var previews: some View { | ||
+ | let host = WoL.Host(host: "test.local", ip: "192.168.0.1", macaddr: "aa:bb:cc:dd:ee:ff", comment: "") | ||
+ | HostView(host: host, isAlert: $isAlert) | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ==メニュー== | ||
+ | ===メインメニューに別のViewを開くメニューを追加=== | ||
+ | ---- | ||
+ | [[File:swift_ui_main_menu.png|400px]] | ||
+ | *.commandsを記述 | ||
+ | <pre> | ||
+ | @main | ||
+ | struct WoLApp: App { | ||
+ | var body: some Scene { | ||
+ | WindowGroup { | ||
+ | ContentView() | ||
+ | }.commands { | ||
+ | CommandGroup(after: CommandGroupPlacement.appInfo) { | ||
+ | Divider() | ||
+ | NavigationLink(destination: PreferenceView()) { | ||
+ | Text("preferences") | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ==図形== | ||
+ | ===Capsule=== | ||
+ | ---- | ||
+ | [[File:swiftui_capsule.png|200px]] | ||
+ | |||
+ | <pre> | ||
+ | VStack{ | ||
+ | : | ||
+ | } | ||
+ | .padding() | ||
+ | .background( | ||
+ | Capsule(style: .continuous) | ||
+ | .foregroundColor(Color.white) | ||
+ | ) | ||
+ | .shadow(radius:10 ) | ||
+ | </pre> | ||
+ | |||
+ | ===RoundedRectangle=== | ||
+ | ---- | ||
+ | [[File:swiftui_roundedRectangle.png|200px]] | ||
+ | <pre> | ||
+ | VStack{ | ||
+ | : | ||
+ | } | ||
+ | .padding() | ||
+ | .background( | ||
+ | RoundedRectangle(cornerRadius: 20) | ||
+ | .foregroundColor(Color.white) | ||
+ | ) | ||
+ | .shadow(radius:10 ) | ||
+ | </pre> | ||
+ | |||
+ | ==画像== | ||
+ | ===SF Symbol アイコン=== | ||
+ | ---- | ||
+ | *https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ | ||
+ | *[https://developer.apple.com/sf-symbols/ SF Symbols アプリ] | ||
+ | |||
+ | ==コードサンプル(コンポーネント)== | ||
+ | ===Button=== | ||
+ | ---- | ||
+ | [[File:swift_sample_button.png|200px]] | ||
+ | <pre> | ||
+ | import Foundation | ||
+ | import SwiftUI | ||
+ | |||
+ | struct ButtonView: View { | ||
+ | |||
+ | @State var cnt:Int = 0; | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | Button(action: { | ||
+ | self.cnt += 1; | ||
+ | print("print \(self.cnt)") | ||
+ | }) | ||
+ | { | ||
+ | Text("Button+1 (\(self.cnt))") | ||
+ | } | ||
+ | .padding(.horizontal, 25.0) | ||
+ | .font(.largeTitle) | ||
+ | .foregroundColor(Color.white) | ||
+ | .background(Color.green) | ||
+ | .cornerRadius(15, antialiased: true) | ||
+ | Divider() | ||
+ | Button("Button+2 (\(self.cnt))") { | ||
+ | self.cnt += 2; | ||
+ | } | ||
+ | .font(.largeTitle) | ||
+ | .foregroundColor(.white) | ||
+ | .background( | ||
+ | Capsule() | ||
+ | .foregroundColor(Color.blue) | ||
+ | .frame(width: 200, height: 60, alignment: .center) | ||
+ | ) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | ===Toggle(@State)=== | ||
+ | ---- | ||
+ | [[File:swift_sample_toggle.png|200px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct ToggleView: View { | ||
+ | @State var isOn = true | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | Toggle(isOn: $isOn) { | ||
+ | Text("On/Off") | ||
+ | .font(.title) | ||
+ | } | ||
+ | .fixedSize() | ||
+ | .padding() | ||
+ | |||
+ | /* https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ | ||
+ | */ | ||
+ | Button(action: { | ||
+ | withAnimation { | ||
+ | self.isOn.toggle() | ||
+ | } | ||
+ | }) { | ||
+ | Image(systemName: self.isOn ? "applewatch" : "applewatch.slash") | ||
+ | .font(.system(size: 60)) | ||
+ | .frame(width: 100, height: 100) | ||
+ | .imageScale(.large) | ||
+ | .rotationEffect(.degrees(isOn ? 0 : 360)) | ||
+ | |||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===Stepper=== | ||
+ | ---- | ||
+ | [[File:swift_sample_stepper.png|200px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct StepperView: View { | ||
+ | @State var cnt = 0; | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | Stepper(value: $cnt, in: 0 ... 5) { | ||
+ | Text("Stepper-\(self.cnt)") | ||
+ | }.frame(width: 200) | ||
+ | Stepper( | ||
+ | onIncrement: { | ||
+ | self.cnt += 5; | ||
+ | }, | ||
+ | onDecrement: { | ||
+ | self.cnt -= 3; | ||
+ | }, | ||
+ | label: { | ||
+ | Text("Stepper-\(self.cnt)") | ||
+ | } | ||
+ | ).frame(width: 200) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===Alert=== | ||
+ | ---- | ||
+ | [[File:swift_sample_alert.png|200px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct AlertView: View { | ||
+ | @State var isAlert = false; | ||
+ | var body: some View { | ||
+ | Button(action: { | ||
+ | self.isAlert = true | ||
+ | }) { | ||
+ | Text("Alert") | ||
+ | .foregroundColor(.white) | ||
+ | }.background( | ||
+ | Capsule() | ||
+ | .foregroundColor(.blue) | ||
+ | .frame(width: 100, height: 40) | ||
+ | ).alert(isPresented: $isAlert, content: { | ||
+ | Alert(title: Text("Title"),message: Text("Messge"), | ||
+ | primaryButton: .default(Text("OK"), action: {}), | ||
+ | secondaryButton: .cancel(Text("Cancel"),action: {})) | ||
+ | }) | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===Tab=== | ||
+ | ---- | ||
+ | [[File:swift_sample_tab.png|200px]] | ||
+ | <pre> | ||
+ | |||
+ | struct TabbedView: View { | ||
+ | @State var selection = 0 | ||
+ | var body: some View { | ||
+ | TabView(selection: $selection) { | ||
+ | ButtonView().tabItem { | ||
+ | Text("Item1") | ||
+ | }.tag(1) | ||
+ | ToggleView().tabItem { | ||
+ | Text("Item2") | ||
+ | }.tag(2) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | ====Tab切り替えイベント==== | ||
+ | ---- | ||
+ | <pre> | ||
+ | @State var selectedTabIndex = 1; | ||
+ | var body: some View { | ||
+ | TabView(selection: $selectedTabIndex) { | ||
+ | arpListView.tabItem { | ||
+ | Text("Tab Label 1") | ||
+ | }.tag(1) | ||
+ | hostListView.tabItem { | ||
+ | Text("Tab Label 2") | ||
+ | }.tag(2) | ||
+ | } | ||
+ | .onChange(of: selectedTabIndex) { value in | ||
+ | print("TAB INDEX \(value)") | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===Binding=== | ||
+ | ---- | ||
+ | [[File:swift_sample_binding.png|200px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct BindingView: View { | ||
+ | @State var isChecked1: Bool = false | ||
+ | @State var isChecked2: Bool = false | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | CheckImageButton(isChecked: $isChecked1) | ||
+ | CheckImageButton(isChecked: $isChecked2) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | struct CheckImageButton: View { | ||
+ | @Binding var isChecked: Bool | ||
+ | var body: some View { | ||
+ | Button(action: { | ||
+ | self.isChecked.toggle() | ||
+ | }) { | ||
+ | Image(systemName: isChecked ? | ||
+ | "person.crop.circle.badge.checkmark": | ||
+ | "person.crop.circle") | ||
+ | .foregroundColor( | ||
+ | isChecked ? .blue : .gray) | ||
+ | } | ||
+ | .imageScale(.large) | ||
+ | .frame(width: 40) | ||
+ | |||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | ====リストのBinding==== | ||
+ | ---- | ||
+ | [[File:swiftui_binding_list.png|500px]] | ||
+ | |||
+ | *Model | ||
+ | <pre> | ||
+ | import Foundation | ||
+ | import AppKit | ||
+ | import SwiftUI | ||
+ | |||
+ | class File : Identifiable, ObservableObject { | ||
+ | let id = UUID() | ||
+ | let path: String | ||
+ | let name: String | ||
+ | let isDirectory: Bool | ||
+ | var isOn: Bool = false | ||
+ | |||
+ | init(path: String, name: String, isDirectory: Bool, isOn: Bool) { | ||
+ | self.path = path | ||
+ | self.name = name | ||
+ | self.isDirectory = isDirectory | ||
+ | self.isOn = isOn | ||
+ | } | ||
+ | } | ||
+ | |||
+ | class FileList : ObservableObject { | ||
+ | @Published var files: [File] = [] | ||
+ | } | ||
+ | </pre> | ||
+ | *UI | ||
+ | **各所(①②③)に$をつける | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct ContentView: View { | ||
+ | @ObservedObject var filePaths = FileList() | ||
+ | @State private var selectedPaths = Set<File.ID>() | ||
+ | |||
+ | var body: some View { | ||
+ | Button(action: { | ||
+ | let rootDir = EncConverterService.chooseDir() | ||
+ | let queue = DispatchQueue.global(qos: .userInitiated) | ||
+ | queue.async { | ||
+ | EncConverterService.loadFile(directoryPath: rootDir, filepaths: self.filePaths) | ||
+ | } | ||
+ | }) { | ||
+ | Text("Choose dir") | ||
+ | } | ||
+ | |||
+ | Table($filePaths.files, selection: $selectedPaths) { // ① | ||
+ | TableColumn("path") { $file in // ② | ||
+ | let isDir = file.isDirectory | ||
+ | HStack { | ||
+ | if !isDir { | ||
+ | Toggle("",isOn: $file.isOn) // ③ | ||
+ | .padding(.leading, (isDir) ? 0 : 20) | ||
+ | } | ||
+ | Text((isDir) ? file.path : file.name) | ||
+ | .font((isDir) ? .headline : .none) | ||
+ | |||
+ | } | ||
+ | } | ||
+ | } | ||
+ | Text("\(selectedPaths.count) path selected") | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===画像=== | ||
+ | ---- | ||
[[File:Swiftui image sample.png|600px]] | [[File:Swiftui image sample.png|600px]] | ||
<pre> | <pre> | ||
42行目: | 680行目: | ||
.foregroundColor(.red) | .foregroundColor(.red) | ||
) | ) | ||
+ | .clipShape(Circle()/) | ||
+ | .shadow(radius: 10) | ||
} | } | ||
} | } | ||
51行目: | 691行目: | ||
} | } | ||
} | } | ||
+ | </pre> | ||
+ | ===図形=== | ||
+ | ---- | ||
+ | [[File:Swiftui_sample_figure.png|600px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct Figure: View { | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | Circle() | ||
+ | .foregroundColor(.blue) | ||
+ | .frame(width: 100.0, height:100.0) | ||
+ | Ellipse() | ||
+ | .foregroundColor(.green) | ||
+ | .frame(width: 200, height: 100.0) | ||
+ | Rectangle() | ||
+ | .foregroundColor(.orange) | ||
+ | .frame(width: 100.0, height: 100.0) | ||
+ | .rotationEffect(.degrees(45)) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | struct Figure_Previews: PreviewProvider { | ||
+ | static var previews: some View { | ||
+ | Figure() | ||
+ | } | ||
+ | } | ||
</pre> | </pre> | ||
− | ===Tips=== | + | ===List=== |
− | + | ---- | |
+ | [[File:Swiftui_list_sample.png|600px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct ListView: View { | ||
+ | var body: some View { | ||
+ | NavigationView { | ||
+ | List { | ||
+ | Text("List Item") | ||
+ | Text("List Item") | ||
+ | HStack { | ||
+ | Image("add_component_swiftui") | ||
+ | .frame(width: 100, height: 100) | ||
+ | .scaleEffect(0.2) | ||
+ | .aspectRatio(contentMode:.fit) | ||
+ | .clipped() | ||
+ | .clipShape(/*@START_MENU_TOKEN@*/Circle()/*@END_MENU_TOKEN@*/) | ||
+ | |||
+ | } | ||
+ | Text("List Item") | ||
+ | Text("List Item") | ||
+ | }.navigationBarTitle("List Title") | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | struct ListView_Previews: PreviewProvider { | ||
+ | static var previews: some View { | ||
+ | ListView() | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ====List(繰り返し、Section)==== | ||
+ | ---- | ||
+ | [[File:Swiftui_list_section.png|600px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | let items = ["item1","item2","item3","item4","item5"]; | ||
+ | struct EmbededList: View { | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | List(0..<items.count) { idx in | ||
+ | Text(items[idx]) | ||
+ | } | ||
+ | .frame(height: 300.0) | ||
+ | List { | ||
+ | Section(header: Text("Section1")) { | ||
+ | ForEach(0 ..< items.count) { idx in | ||
+ | Text(items[idx]) | ||
+ | } | ||
+ | } | ||
+ | Section(header: Text("Section2")) { | ||
+ | ForEach(0 ..< items.count) { idx in | ||
+ | Text(items[idx]) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | |||
+ | struct EmbededList_Previews: PreviewProvider { | ||
+ | static var previews: some View { | ||
+ | EmbededList() | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ====Listにオブジェクトを表示==== | ||
+ | ---- | ||
+ | [[File:swift_ui_object_load_to_list.png|500px]] | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct ContentView: View { | ||
+ | @ObservedObject var hosts = HostList() | ||
+ | var body: some View { | ||
+ | VStack { | ||
+ | HStack { | ||
+ | Button(action: { | ||
+ | WoLService().arp(hosts:self.hosts) | ||
+ | }) { | ||
+ | Text("arp -a") | ||
+ | }.padding() | ||
+ | } | ||
+ | Divider() | ||
+ | List (hosts.hosts, id: \.ip){ host in | ||
+ | Text(host.host) | ||
+ | Text(host.ip) | ||
+ | Text(host.macaddr) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | * | ||
+ | <pre> | ||
+ | import Foundation | ||
+ | |||
+ | class HostList : ObservableObject { | ||
+ | @Published var hosts: [Host] = [] | ||
+ | } | ||
+ | |||
+ | class Host { | ||
+ | var host: String = "" | ||
+ | var ip: String = "" | ||
+ | var macaddr: String = "" | ||
+ | } | ||
+ | </pre> | ||
+ | ===Table=== | ||
+ | ---- | ||
+ | *https://developer.apple.com/documentation/swiftui/table | ||
+ | |||
+ | [[File:swiftui_tableview.png|500px]] | ||
+ | |||
+ | *Service | ||
+ | <pre> | ||
+ | import Foundation | ||
+ | import AppKit | ||
+ | import SwiftUI | ||
+ | |||
+ | class File : Identifiable { | ||
+ | let id = UUID() | ||
+ | let path: String | ||
+ | |||
+ | init(path: String) { | ||
+ | self.path = path | ||
+ | } | ||
+ | } | ||
+ | |||
+ | class FileList : ObservableObject { | ||
+ | @Published var files: [File] = [] | ||
+ | } | ||
+ | |||
+ | public struct EncConverterService { | ||
+ | static func chooseDir() -> String { | ||
+ | |||
+ | let dialog = NSOpenPanel(); | ||
+ | |||
+ | dialog.title = "Choose a file| Our Code World" | ||
+ | dialog.showsResizeIndicator = true | ||
+ | dialog.showsHiddenFiles = false | ||
+ | dialog.allowsMultipleSelection = false | ||
+ | dialog.canChooseDirectories = true | ||
+ | dialog.canChooseFiles = false | ||
+ | |||
+ | if (dialog.runModal() == NSApplication.ModalResponse.OK) { | ||
+ | let result = dialog.url | ||
+ | |||
+ | if (result != nil) { | ||
+ | let path: String = result!.path | ||
+ | return path | ||
+ | } | ||
+ | |||
+ | } | ||
+ | return ""; | ||
+ | } | ||
+ | |||
+ | static func loadFile(directoryPath: String, filepaths: FileList) { | ||
+ | print(directoryPath) | ||
+ | filepaths.files.append(File(path: directoryPath)) | ||
+ | do { | ||
+ | let fm = FileManager.default | ||
+ | let fileNames = try fm.contentsOfDirectory(atPath: directoryPath) | ||
+ | |||
+ | for fileName in fileNames { | ||
+ | let childPath = directoryPath + "/" + fileName | ||
+ | |||
+ | var isDir = ObjCBool(false) | ||
+ | fm.fileExists(atPath: childPath, isDirectory: &isDir) | ||
+ | if isDir.boolValue { | ||
+ | loadFile(directoryPath: childPath, filepaths: filepaths) | ||
+ | } | ||
+ | print("\t" + childPath) | ||
+ | filepaths.files.append(File(path: childPath)) | ||
+ | } | ||
+ | } catch { | ||
+ | print(error) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | *View | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct ContentView: View { | ||
+ | @ObservedObject var filePaths = FileList() | ||
+ | @State private var selectedPaths = Set<File.ID>() | ||
+ | |||
+ | var body: some View { | ||
+ | Button(action: { | ||
+ | let rootDir = EncConverterService.chooseDir() | ||
+ | let queue = DispatchQueue.global(qos: .userInitiated) | ||
+ | queue.async { | ||
+ | EncConverterService.loadFile(directoryPath: rootDir, filepaths: self.filePaths) | ||
+ | } | ||
+ | }) { | ||
+ | Text("Choose dir") | ||
+ | } | ||
+ | |||
+ | Table(filePaths.files, selection: $selectedPaths) { | ||
+ | TableColumn("Path", value: \.path) | ||
+ | } | ||
+ | Text("\(selectedPaths.count) path selected") | ||
+ | |||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ==コードサンプル(ロジック)== | ||
+ | |||
+ | ===Observable(@ObservedObject,@Published,@State)=== | ||
+ | ---- | ||
+ | #データクラスはObservableObjectプロトコル準拠とする。 | ||
+ | #監視対象とするプロパティに@Published属性を付加する。 | ||
+ | #<u>データクラスのインスタンスは@ObservedObject属性を付加してViewの中で宣言する</u> | ||
+ | [[File:swift_sample_observable.png|200px]] | ||
+ | *Publish | ||
+ | <pre> | ||
+ | import Foundation | ||
+ | |||
+ | class PublishObject: ObservableObject { | ||
+ | @Published var counter: Int = 0 | ||
+ | |||
+ | var timer = Timer() | ||
+ | |||
+ | func start() { | ||
+ | timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in | ||
+ | self.counter += 1 | ||
+ | } | ||
+ | } | ||
+ | |||
+ | func stop() { | ||
+ | timer.invalidate() | ||
+ | } | ||
+ | |||
+ | func reset() { | ||
+ | timer.invalidate() | ||
+ | counter = 0 | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | *Subscribe | ||
+ | <pre> | ||
+ | import SwiftUI | ||
+ | |||
+ | struct SubscriberView: View { | ||
+ | @ObservedObject var publisher = PublishObject() | ||
+ | |||
+ | let currentTimer = Timer.TimerPublisher(interval: 1.0, runLoop: .main, mode: .default).autoconnect() | ||
+ | @State var now = Date() | ||
+ | |||
+ | var body: some View { | ||
+ | VStack { | ||
+ | Text("\(self.now.description)") | ||
+ | HStack { | ||
+ | Button(action: { | ||
+ | self.publisher.start() | ||
+ | }){ | ||
+ | Image(systemName: "play") | ||
+ | }.padding() | ||
+ | Button(action: { | ||
+ | self.publisher.stop() | ||
+ | }){ | ||
+ | Image(systemName: "pause") | ||
+ | }.padding() | ||
+ | Button(action: { | ||
+ | self.publisher.reset() | ||
+ | }){ | ||
+ | Image(systemName: "backward.end") | ||
+ | }.padding() | ||
+ | } | ||
+ | .frame(width:200) | ||
+ | |||
+ | Text("\(self.publisher.counter)") | ||
+ | |||
+ | }.font(.largeTitle) | ||
+ | .onReceive(currentTimer) { date in | ||
+ | self.now = date | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | ===動的に検索=== | ||
+ | [[File:swiftui_dynamic_search.png|400px]] | ||
+ | |||
+ | <pre> | ||
+ | struct HostListView: View { | ||
+ | @ObservedObject var hosts = HostList() | ||
+ | @ObservedObject var param = HostViewParameter() | ||
+ | // 検索キーワード | ||
+ | @State var searchKeyword = "" | ||
+ | |||
+ | var body: some View { | ||
+ | VStack { | ||
+ | let columns = | ||
+ | [GridItem(.adaptive(minimum: 250, maximum: 800))] | ||
+ | ScrollView { | ||
+ | LazyVGrid(columns: columns, spacing:10) { | ||
+ | // Arrayにfilterを適用 | ||
+ | ForEach(hosts.hosts.filter({ (host) -> Bool in | ||
+ | if searchKeyword != "" { | ||
+ | return host.host.contains(searchKeyword) || | ||
+ | host.ip.contains(searchKeyword) || | ||
+ | host.comment.contains(searchKeyword) | ||
+ | } | ||
+ | return true | ||
+ | }), id: \.id) { host in | ||
+ | HostView(host:host).environmentObject(param) | ||
+ | } | ||
+ | } .padding(20) | ||
+ | }.toolbar { | ||
+ | : | ||
+ | ToolbarItem(placement: .automatic) { | ||
+ | TextField("search...", text: $searchKeyword) | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ===バックグラウンドからUIを操作する=== | ||
+ | ---- | ||
+ | *observableobj が、ObservableObject の派生クラス | ||
+ | *contentフィールドに、@Published アノテーション | ||
+ | *Viewで、@ObservedObjectを付与しインスタンスを生成 | ||
+ | *上記で、バックグラウンドから、observableobj.contentを操作すると、UIはメインスレッドから触るように怒られる。 | ||
+ | <blockquote>Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.</blockquote> | ||
+ | *DispatchQueue.main.syncで囲む | ||
+ | <pre> | ||
+ | DispatchQueue.main.sync { | ||
+ | observableobj.content = text | ||
+ | } | ||
+ | </pre> | ||
+ | |||
+ | ==Tips== | ||
+ | ---- | ||
+ | ===[https://www.typea.info/blog/index.php/2021/11/30/swift_macos_app_permission/ ファイルパーミッションエラー(App Sandbox)]=== | ||
+ | ---- | ||
+ | *[https://www.typea.info/blog/index.php/2021/11/30/swift_macos_app_permission/ ファイルパーミッションエラー(App Sandbox)] | ||
+ | ===[https://www.typea.info/blog/index.php/2021/01/23/swiftui_tips_view_component_locate/ 画面部品の追加方法]=== | ||
+ | ---- | ||
+ | |||
+ | ===SwiftUIライブラリ=== | ||
+ | ---- | ||
+ | ====[https://github.com/SwiftUIX/SwiftUIX SwiftUIX]==== | ||
+ | [https://qiita.com/yosshi4486/items/3d92f81feaabc1049b4c SwiftUIアプリケーション開発の不足を補うSwiftUIX] | ||
+ | |||
+ | ===ファイル選択=== | ||
+ | ---- | ||
+ | <pre> | ||
+ | let dialog = NSOpenPanel(); | ||
+ | |||
+ | dialog.title = "Choose a file" | ||
+ | dialog.showsResizeIndicator = true | ||
+ | dialog.showsHiddenFiles = false | ||
+ | dialog.allowsMultipleSelection = false | ||
+ | dialog.canChooseDirectories = false | ||
+ | |||
+ | if (dialog.runModal() == NSApplication.ModalResponse.OK) { | ||
+ | let result = dialog.url | ||
+ | if (result != nil) { | ||
+ | let path: String = result!.path | ||
+ | print(path) | ||
+ | } | ||
+ | |||
+ | } else { | ||
+ | return | ||
+ | } | ||
+ | </pre> |
2024年8月14日 (水) 07:37時点における最新版
| Swift | Mac | Xcode | Swift Sample | Cocoa | Xamarin.Mac |
SwiftUI
- SwiftUI
- SwiftUI Documents
- macos tutorials
- 1セットのツールとAPIを使用するだけで、あらゆるAppleデバイス向けのユーザーインターフェイスを構築
- 宣言型シンタックスを使
- 宣言型のスタイルは、アニメーションなどの複雑な概念にも適用
デザインツール
- Xcodeには、SwiftUIでのインターフェイス構築をドラッグ&ドロップのように簡単に行える直感的な新しいデザインツールが含まれています
- デザインキャンバスでの編集内容と、隣接するエディタ内のコードはすべて完全に同期されます
ドラッグ&ドロップ
- ユーザーインターフェイス内のコンポーネントの位置は、キャンバス上でコントロールをドラッグするだけで調整できます
ダイナミックリプレースメント
- wiftのコンパイラとランタイムはXcode全体に完全に埋め込まれているため、Appは常にビルドされ実行されます
- 表示されるデザインキャンバスは、単にユーザーインターフェイスに似せたものではなく、実際のAppそのもの
- Xcodeは編集したコードを実際のAppに直接組み入れることができます
プレビュー
- プレビューを1つまたは複数作成して、サンプルデータを取得できる
Swift UI チュートリアルをやってみる
- プロジェクト作成〜TextViewのカスタマイズ
- Xcode
- resume -> プレビュー
- command + click -> Action List
- 右上の + ボタンでコントロール追加
Xcode ナビゲーター
- プロジェクトナビゲーター
- ソースコントロールナビゲーター
- シンボルナビゲーター
- 検索ナビゲーター
- イシューナビゲーター
- テストナビゲーター
- デバッグナビゲーター
- ブレークポイントナビゲーター
- レポートナビゲーター
プロジェクト作成〜TextViewのカスタマイズ
Custom Image Viewの作成
Xcodeを使ってmacOS プログラミングとplaygroundの作成
Listとナビゲーションとプレビュー
レイアウト
Alignment
余白の取り方
- 余白の取り方
- 記述箇所によって、表示が変わる
- backgroundにpaddingを指定する(cssのmargin的な効果)
Text(host.host) .background(Color.green) .padding()
- Textにpaddingを指定することになる(cssのpadding的効果)
Text(host.host) .padding() .background(Color.green)
ボタンサイズ
- ボタンのサイズを内容にフィットさせたい
Button(action: {}) { VStack{ : } .padding() .border(Color.blue, width: 3) }
- .buttonStyle(PlainButtonStyle()) を指定
Button(action: {}) { VStack{ : } .padding() .border(Color.blue, width: 3) }.buttonStyle(PlainButtonStyle())
親Viewのサイズ情報を取得する
- https://qiita.com/masa7351/items/0567969f93cc88d714ac
- https://www.hackingwithswift.com/quick-start/swiftui/how-to-make-two-views-the-same-width-or-height
struct HostView : View { var host: WoL.Host var body: some View { GeometryReader { geo in HStack() { Text(host.host) .padding() .frame(width: geo.size.width * 0.33 , alignment: .leading) .frame(maxHeight: .infinity) .background(Color.red) Text(host.ip) .padding() .frame(width: geo.size.width * 0.33 , alignment: .leading) .frame(maxHeight: .infinity) .background(Color.green) Text(host.macaddr) .padding() .frame(width: geo.size.width * 0.33 , alignment: .leading) .frame(maxHeight: .infinity) .background(Color.yellow) }.fixedSize(horizontal: false, vertical: true) }.frame(height: 60) } }
影をつける
---
- shadowを設定すると、内部のコンポーネント全てに影がつく、compositingGroup を指定することで、背景だけに影をつけることができる
: .background() .compositingGroup() .shadow(radius: 10)
Card View
- Card View
- LazyGrid
let columns = [GridItem(.adaptive(minimum: 250, maximum: 800))] ScrollView { LazyVGrid(columns: columns, spacing:10) { ForEach(hosts.hosts, id: \.macaddr) { host in HostView(host:host) } } .padding(20)
Toolbar
ScrollView { LazyVGrid(columns: columns) { ForEach(hosts.hosts, id: \.ip) { host in HostView(host:host) } } .padding(20) }.toolbar { ToolbarItem(placement: .automatic) { Button("arp -a") { WoLService().arp(hosts:self.hosts) } } ToolbarItem(placement: .automatic) { Button("load") { WoLService().load(hosts:self.hosts) } } }
データ
データ変更に応じて画面に反映させる
- 画面の構造体の中でデータを保持しているクラスのインスタンスを格納するプロパティに、@ObservedObjectプロパティラッパーを付与する
- データを保持しているクラスを、@ObservableObjectプロトコルに準拠させる
- クラスの中で変更を反映させる値を保持しているプロパティに、@Publishedプロパティラッパーを付与する
UserDefaults
ドキュメントパス
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) print("Document path: \(paths)")
- 出力
Document path: [file:///Users/hirotoyagi/Library/Containers/info.typea.WoL/Data/Documents/]
ファイル格納先
View間データ受け渡し
SwiftUIでは親ビューと子ビュー間で値を渡す方法は、下記の3つが挙げられます。
- Environment
- Viewが持つ環境変数。独自の環境変数を定義することができ、それを利用して親ビューから任意の値を渡すことが可能
- EnvironmentObjects
- 他の2つに比べて一般的な方法。利用するためには独自のクラスを定義
- Preferences
- Preferenceは子から親へ伝達させる方法
ObservableObject を経由して親子ViewでAlertの表示フラグを共有
- ObservableObjectを共有して、変更をSubscribeする
struct HostListView: View { @ObservedObject var hosts = HostList() @ObservedObject var param = Param() var body: some View { VStack { let columns = [GridItem(.adaptive(minimum: 250, maximum: 800))] ScrollView { LazyVGrid(columns: columns, spacing:10) { ForEach(hosts.hosts, id: \.id) { host in HostView(host:host).environmentObject(param) } } .padding(20) }.alert(isPresented: $param.isSaveAlert, content: { Alert(title: Text("Title"),message: Text("Messge"), primaryButton: .default(Text("OK"), action: {}), secondaryButton: .cancel(Text("Cancel"),action: {})) }) } } } struct HostView : View { @ObservedObject var host: WoL.Host @EnvironmentObject var param: Param var body: some View { VStack{ HStack { Button(action: { self.param.isSaveAlert = true }) { Image(systemName: "square.and.arrow.down") }.help(Text("save")) } : } } } class Param : ObservableObject { @Published var isSaveAlert: Bool = false }
@Binding を経由して親子ViewでAlertの表示フラグを共有
- @Binding でView間で変数を共有
- 呼び出し側は@State
struct HostListView: View { @ObservedObject var hosts = HostList() @State var isSaveAlert = false var body: some View { VStack { let columns = [GridItem(.adaptive(minimum: 250, maximum: 800))] ScrollView { LazyVGrid(columns: columns, spacing:10) { ForEach(hosts.hosts, id: \.id) { host in HostView(host:host, isSaveAlert: $isSaveAlert, alertMessage: $alertMessage) } } .padding(20) }.alert(isPresented: $isSaveAlert, content: { Alert(title: Text("Title"),message: Text(alertMessage), primaryButton: .default(Text("OK"), action: {}), secondaryButton: .cancel(Text("Cancel"),action: {})) }) } } } struct HostView : View { @ObservedObject var host: WoL.Host @Binding var isSaveAlert: Bool var body: some View { VStack{ HStack { Button(action: { self.isSaveAlert = true }) { Image(systemName: "square.and.arrow.down") }.help(Text("save")) : } } } }
@Binding使用時のPreview
- @Stateかつ、staticで宣言
- $で変数を渡す
struct HostView_Previews: PreviewProvider { @State static var isAlert = true static var previews: some View { let host = WoL.Host(host: "test.local", ip: "192.168.0.1", macaddr: "aa:bb:cc:dd:ee:ff", comment: "") HostView(host: host, isAlert: $isAlert) } }
メニュー
メインメニューに別のViewを開くメニューを追加
- .commandsを記述
@main struct WoLApp: App { var body: some Scene { WindowGroup { ContentView() }.commands { CommandGroup(after: CommandGroupPlacement.appInfo) { Divider() NavigationLink(destination: PreferenceView()) { Text("preferences") } } } } }
図形
Capsule
VStack{ : } .padding() .background( Capsule(style: .continuous) .foregroundColor(Color.white) ) .shadow(radius:10 )
RoundedRectangle
VStack{ : } .padding() .background( RoundedRectangle(cornerRadius: 20) .foregroundColor(Color.white) ) .shadow(radius:10 )
画像
SF Symbol アイコン
コードサンプル(コンポーネント)
Button
import Foundation import SwiftUI struct ButtonView: View { @State var cnt:Int = 0; var body: some View { VStack { Button(action: { self.cnt += 1; print("print \(self.cnt)") }) { Text("Button+1 (\(self.cnt))") } .padding(.horizontal, 25.0) .font(.largeTitle) .foregroundColor(Color.white) .background(Color.green) .cornerRadius(15, antialiased: true) Divider() Button("Button+2 (\(self.cnt))") { self.cnt += 2; } .font(.largeTitle) .foregroundColor(.white) .background( Capsule() .foregroundColor(Color.blue) .frame(width: 200, height: 60, alignment: .center) ) } } }
Toggle(@State)
import SwiftUI struct ToggleView: View { @State var isOn = true var body: some View { VStack { Toggle(isOn: $isOn) { Text("On/Off") .font(.title) } .fixedSize() .padding() /* https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ */ Button(action: { withAnimation { self.isOn.toggle() } }) { Image(systemName: self.isOn ? "applewatch" : "applewatch.slash") .font(.system(size: 60)) .frame(width: 100, height: 100) .imageScale(.large) .rotationEffect(.degrees(isOn ? 0 : 360)) } } } }
Stepper
import SwiftUI struct StepperView: View { @State var cnt = 0; var body: some View { VStack { Stepper(value: $cnt, in: 0 ... 5) { Text("Stepper-\(self.cnt)") }.frame(width: 200) Stepper( onIncrement: { self.cnt += 5; }, onDecrement: { self.cnt -= 3; }, label: { Text("Stepper-\(self.cnt)") } ).frame(width: 200) } } }
Alert
import SwiftUI struct AlertView: View { @State var isAlert = false; var body: some View { Button(action: { self.isAlert = true }) { Text("Alert") .foregroundColor(.white) }.background( Capsule() .foregroundColor(.blue) .frame(width: 100, height: 40) ).alert(isPresented: $isAlert, content: { Alert(title: Text("Title"),message: Text("Messge"), primaryButton: .default(Text("OK"), action: {}), secondaryButton: .cancel(Text("Cancel"),action: {})) }) } }
Tab
struct TabbedView: View { @State var selection = 0 var body: some View { TabView(selection: $selection) { ButtonView().tabItem { Text("Item1") }.tag(1) ToggleView().tabItem { Text("Item2") }.tag(2) } } }
Tab切り替えイベント
@State var selectedTabIndex = 1; var body: some View { TabView(selection: $selectedTabIndex) { arpListView.tabItem { Text("Tab Label 1") }.tag(1) hostListView.tabItem { Text("Tab Label 2") }.tag(2) } .onChange(of: selectedTabIndex) { value in print("TAB INDEX \(value)") } }
Binding
import SwiftUI struct BindingView: View { @State var isChecked1: Bool = false @State var isChecked2: Bool = false var body: some View { VStack { CheckImageButton(isChecked: $isChecked1) CheckImageButton(isChecked: $isChecked2) } } } struct CheckImageButton: View { @Binding var isChecked: Bool var body: some View { Button(action: { self.isChecked.toggle() }) { Image(systemName: isChecked ? "person.crop.circle.badge.checkmark": "person.crop.circle") .foregroundColor( isChecked ? .blue : .gray) } .imageScale(.large) .frame(width: 40) } }
リストのBinding
- Model
import Foundation import AppKit import SwiftUI class File : Identifiable, ObservableObject { let id = UUID() let path: String let name: String let isDirectory: Bool var isOn: Bool = false init(path: String, name: String, isDirectory: Bool, isOn: Bool) { self.path = path self.name = name self.isDirectory = isDirectory self.isOn = isOn } } class FileList : ObservableObject { @Published var files: [File] = [] }
- UI
- 各所(①②③)に$をつける
import SwiftUI struct ContentView: View { @ObservedObject var filePaths = FileList() @State private var selectedPaths = Set<File.ID>() var body: some View { Button(action: { let rootDir = EncConverterService.chooseDir() let queue = DispatchQueue.global(qos: .userInitiated) queue.async { EncConverterService.loadFile(directoryPath: rootDir, filepaths: self.filePaths) } }) { Text("Choose dir") } Table($filePaths.files, selection: $selectedPaths) { // ① TableColumn("path") { $file in // ② let isDir = file.isDirectory HStack { if !isDir { Toggle("",isOn: $file.isOn) // ③ .padding(.leading, (isDir) ? 0 : 20) } Text((isDir) ? file.path : file.name) .font((isDir) ? .headline : .none) } } } Text("\(selectedPaths.count) path selected") } }
画像
import SwiftUI struct ContentView: View { var body: some View { VStack { Image("add_component_swiftui").resizable() .aspectRatio(contentMode:.fill) .frame(width: 400, height: 400) .scaleEffect(1.2) .offset(x: -60, y: 0) .clipped() .overlay( Text("SwiftUI Sample") .font(.title) .foregroundColor(.red) ) .clipShape(Circle()/) .shadow(radius: 10) } } } struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } }
図形
import SwiftUI struct Figure: View { var body: some View { VStack { Circle() .foregroundColor(.blue) .frame(width: 100.0, height:100.0) Ellipse() .foregroundColor(.green) .frame(width: 200, height: 100.0) Rectangle() .foregroundColor(.orange) .frame(width: 100.0, height: 100.0) .rotationEffect(.degrees(45)) } } } struct Figure_Previews: PreviewProvider { static var previews: some View { Figure() } }
List
import SwiftUI struct ListView: View { var body: some View { NavigationView { List { Text("List Item") Text("List Item") HStack { Image("add_component_swiftui") .frame(width: 100, height: 100) .scaleEffect(0.2) .aspectRatio(contentMode:.fit) .clipped() .clipShape(/*@START_MENU_TOKEN@*/Circle()/*@END_MENU_TOKEN@*/) } Text("List Item") Text("List Item") }.navigationBarTitle("List Title") } } } struct ListView_Previews: PreviewProvider { static var previews: some View { ListView() } }
List(繰り返し、Section)
import SwiftUI let items = ["item1","item2","item3","item4","item5"]; struct EmbededList: View { var body: some View { VStack { List(0..<items.count) { idx in Text(items[idx]) } .frame(height: 300.0) List { Section(header: Text("Section1")) { ForEach(0 ..< items.count) { idx in Text(items[idx]) } } Section(header: Text("Section2")) { ForEach(0 ..< items.count) { idx in Text(items[idx]) } } } } } } struct EmbededList_Previews: PreviewProvider { static var previews: some View { EmbededList() } }
Listにオブジェクトを表示
import SwiftUI struct ContentView: View { @ObservedObject var hosts = HostList() var body: some View { VStack { HStack { Button(action: { WoLService().arp(hosts:self.hosts) }) { Text("arp -a") }.padding() } Divider() List (hosts.hosts, id: \.ip){ host in Text(host.host) Text(host.ip) Text(host.macaddr) } } } }
import Foundation class HostList : ObservableObject { @Published var hosts: [Host] = [] } class Host { var host: String = "" var ip: String = "" var macaddr: String = "" }
Table
- Service
import Foundation import AppKit import SwiftUI class File : Identifiable { let id = UUID() let path: String init(path: String) { self.path = path } } class FileList : ObservableObject { @Published var files: [File] = [] } public struct EncConverterService { static func chooseDir() -> String { let dialog = NSOpenPanel(); dialog.title = "Choose a file| Our Code World" dialog.showsResizeIndicator = true dialog.showsHiddenFiles = false dialog.allowsMultipleSelection = false dialog.canChooseDirectories = true dialog.canChooseFiles = false if (dialog.runModal() == NSApplication.ModalResponse.OK) { let result = dialog.url if (result != nil) { let path: String = result!.path return path } } return ""; } static func loadFile(directoryPath: String, filepaths: FileList) { print(directoryPath) filepaths.files.append(File(path: directoryPath)) do { let fm = FileManager.default let fileNames = try fm.contentsOfDirectory(atPath: directoryPath) for fileName in fileNames { let childPath = directoryPath + "/" + fileName var isDir = ObjCBool(false) fm.fileExists(atPath: childPath, isDirectory: &isDir) if isDir.boolValue { loadFile(directoryPath: childPath, filepaths: filepaths) } print("\t" + childPath) filepaths.files.append(File(path: childPath)) } } catch { print(error) } } }
- View
import SwiftUI struct ContentView: View { @ObservedObject var filePaths = FileList() @State private var selectedPaths = Set<File.ID>() var body: some View { Button(action: { let rootDir = EncConverterService.chooseDir() let queue = DispatchQueue.global(qos: .userInitiated) queue.async { EncConverterService.loadFile(directoryPath: rootDir, filepaths: self.filePaths) } }) { Text("Choose dir") } Table(filePaths.files, selection: $selectedPaths) { TableColumn("Path", value: \.path) } Text("\(selectedPaths.count) path selected") } }
コードサンプル(ロジック)
Observable(@ObservedObject,@Published,@State)
- データクラスはObservableObjectプロトコル準拠とする。
- 監視対象とするプロパティに@Published属性を付加する。
- データクラスのインスタンスは@ObservedObject属性を付加してViewの中で宣言する
- Publish
import Foundation class PublishObject: ObservableObject { @Published var counter: Int = 0 var timer = Timer() func start() { timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in self.counter += 1 } } func stop() { timer.invalidate() } func reset() { timer.invalidate() counter = 0 } }
- Subscribe
import SwiftUI struct SubscriberView: View { @ObservedObject var publisher = PublishObject() let currentTimer = Timer.TimerPublisher(interval: 1.0, runLoop: .main, mode: .default).autoconnect() @State var now = Date() var body: some View { VStack { Text("\(self.now.description)") HStack { Button(action: { self.publisher.start() }){ Image(systemName: "play") }.padding() Button(action: { self.publisher.stop() }){ Image(systemName: "pause") }.padding() Button(action: { self.publisher.reset() }){ Image(systemName: "backward.end") }.padding() } .frame(width:200) Text("\(self.publisher.counter)") }.font(.largeTitle) .onReceive(currentTimer) { date in self.now = date } } }
動的に検索
struct HostListView: View { @ObservedObject var hosts = HostList() @ObservedObject var param = HostViewParameter() // 検索キーワード @State var searchKeyword = "" var body: some View { VStack { let columns = [GridItem(.adaptive(minimum: 250, maximum: 800))] ScrollView { LazyVGrid(columns: columns, spacing:10) { // Arrayにfilterを適用 ForEach(hosts.hosts.filter({ (host) -> Bool in if searchKeyword != "" { return host.host.contains(searchKeyword) || host.ip.contains(searchKeyword) || host.comment.contains(searchKeyword) } return true }), id: \.id) { host in HostView(host:host).environmentObject(param) } } .padding(20) }.toolbar { : ToolbarItem(placement: .automatic) { TextField("search...", text: $searchKeyword) } } } } }
バックグラウンドからUIを操作する
- observableobj が、ObservableObject の派生クラス
- contentフィールドに、@Published アノテーション
- Viewで、@ObservedObjectを付与しインスタンスを生成
- 上記で、バックグラウンドから、observableobj.contentを操作すると、UIはメインスレッドから触るように怒られる。
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
- DispatchQueue.main.syncで囲む
DispatchQueue.main.sync { observableobj.content = text }
Tips
ファイルパーミッションエラー(App Sandbox)
画面部品の追加方法
SwiftUIライブラリ
SwiftUIX
SwiftUIアプリケーション開発の不足を補うSwiftUIX
ファイル選択
let dialog = NSOpenPanel(); dialog.title = "Choose a file" dialog.showsResizeIndicator = true dialog.showsHiddenFiles = false dialog.allowsMultipleSelection = false dialog.canChooseDirectories = false if (dialog.runModal() == NSApplication.ModalResponse.OK) { let result = dialog.url if (result != nil) { let path: String = result!.path print(path) } } else { return }
© 2006 矢木浩人