「SwiftUI」の版間の差分
ナビゲーションに移動
検索に移動
(→Tips) |
(→Tips) |
||
| 1,065行目: | 1,065行目: | ||
==Tips== | ==Tips== | ||
| − | + | ||
===VSCode + Github Copilot で iPhone アプリ開発=== | ===VSCode + Github Copilot で iPhone アプリ開発=== | ||
*https://www.typea.info/blog/index.php/2024/12/29/vscode_and_github_copilot_dev_iphone_app/ | *https://www.typea.info/blog/index.php/2024/12/29/vscode_and_github_copilot_dev_iphone_app/ | ||
2024年12月29日 (日) 07:39時点における最新版
| 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
VSCode + Github Copilot で iPhone アプリ開発
ファイルパーミッションエラー(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 矢木浩人


