Upgrade to Pro — share decks privately, control downloads, hide ads and more …

『SwiftUIならiOS, macOSの両方で動くエディタアプリが簡単に作れる』 と思ったら...

FromAtom
January 25, 2024

『SwiftUIならiOS, macOSの両方で動くエディタアプリが簡単に作れる』 と思ったら大間違いだよ! / pixiv App Night 2024-01-25

みなさんエディタアプリを作っていると思いますが、できればUIKitとAppKitで別実装はせずSwiftUIだけで完結したいですよね。私もそう思ってました。この発表では、私が個人開発しているエディタアプリをmacOS対応している際に出会った、悲しい現実と力強い対処法をまとめて紹介します。

FromAtom

January 25, 2024
Tweet

More Decks by FromAtom

Other Decks in Technology

Transcript

  1. import SwiftUI struct ContentView: View { @State var text: String

    = "" var body: some View { TextEditor(text: $text) .padding() } }
  2. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  3. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  4. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  5. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  6. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  7. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  8. ରԠҊ macOS SDK Catalyst Designed for iPad SwiftUI.TextEditor ̋ ✕

    ✕ UIKit + Storyboard ϏϧυෆՄ ✕ ✕ AppKit ̋ ϏϧυෆՄ ϏϧυෆՄ UIViewRepresentable (UITextView) ϏϧυෆՄ ✕ ✕ NSViewRepresentable (NSTextView) ̋ ϏϧυෆՄ ϏϧυෆՄ
  9. onChangeͷ৔߹ struct ContentView: View { @State var text: String =

    "" var body: some View { TextEditor(text: $text) // iOS 14.0–17.0 Deprecated .onChange(of: text, perform: { newValue in // do something }) // iOS 17.0+ .onChange(of: text) { oldValue, newValue in // do something } } } • text͕มߋ͞ΕΔͨͼʹൃՐ • ࠩ෼͸ੜ੒Ͱ͖Δ ‣ ͲͷߦͰEnter͞Ε͔ͨܭࢉෆՄ ‣ SelectedRange͕औಘෆՄ • ࢖͑ͳ͍
  10. onSubmitͷ৔߹ struct ContentView: View { @State var text: String =

    "" @FocusState private var isFocused: Bool var body: some View { TextEditor(text: $text) .onSubmit { } } } • TextEditorͰ͸ൃՐ͠ͳ͍ ‣ ͳΜͰʁ • lineLimitΛࢦఆͯ͠΋μϝ ‣ ͔ͯࢦఆͯ͠΋ޮ͔ͳ͍ • submitLabelΛࢦఆͯ͠΋μϝ • ࢖͑ͳ͍
  11. onKeyPressͷ৔߹ struct ContentView: View { @State var text: String =

    "" var body: some View { TextEditor(text: $text) .onKeyPress(.return, action: { print("Enter") return .ignored }) } } • ϋʔυ΢ΣΞΩʔϘʔυͷΈ • ม׵֬ఆͷEnterͰ΋ൃՐ ‣ markedTextRange͸औಘෆՄ • ࢖͑ͳ͍
  12. swiftui-introspectͷͬ͘͟Γઆ໌ • SwiftUIͷཪଆʹ͍ΔUIKit / AppKitཁૉʹΞΫηεͰ͖Δ • TextEditorͷཪʹ͍ΔUITextView / NSTextView͕औಘͰ͖Δ TextEditor(text:

    $text) .introspect(.textEditor, on: .iOS(.v16, .v17)) { print(type(of: $0)) // => UITextView } .introspect(.textEditor, on: .macOS(.v13, .v14)) { print(type(of: $0)) // => NSTextView }
  13. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect struct ContentView: View { @State

    var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() final class Delegate: NSObject, NSTextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } var body: some View { TextEditor(text: $text) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } } }
  14. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect struct ContentView: View { @State

    var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() final class Delegate: NSObject, NSTextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } var body: some View { TextEditor(text: $text) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } } }
  15. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect struct ContentView: View { @State

    var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() final class Delegate: NSObject, NSTextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } var body: some View { TextEditor(text: $text) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } } }
  16. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect struct ContentView: View { @State

    var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() final class Delegate: NSObject, NSTextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } var body: some View { TextEditor(text: $text) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } } }
  17. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect struct ContentView: View { @State

    var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() final class Delegate: NSObject, NSTextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } var body: some View { TextEditor(text: $text) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } } } ͋ͱ͸ίπίπ࣮૷͢Ε͹OK
  18. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect #if os(macOS) import AppKit public

    typealias TextView = NSTextView public typealias TextViewDelegate = NSTextViewDelegate #elseif os(iOS) import UIKit public typealias TextView = UITextView public typealias TextViewDelegate = UITextViewDelegate #endif struct EditorView: View { @State var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() #if os(macOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } #elseif os(iOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard textView.markedTextRange == nil else { return true } if text == "\n" { print("Enterが入力されたよ") } return true } } #endif var body: some View { TextEditor(text: $text) #if os(macOS) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } #elseif os(iOS) .introspect(.textEditor, on: .iOS(.v16, .v17)) { textView in self.textView = textView } #endif } }
  19. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect #if os(macOS) import AppKit public

    typealias TextView = NSTextView public typealias TextViewDelegate = NSTextViewDelegate #elseif os(iOS) import UIKit public typealias TextView = UITextView public typealias TextViewDelegate = UITextViewDelegate #endif struct EditorView: View { @State var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() #if os(macOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } #elseif os(iOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard textView.markedTextRange == nil else { return true } if text == "\n" { print("Enterが入力されたよ") } return true } } #endif var body: some View { TextEditor(text: $text) #if os(macOS) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } #elseif os(iOS) .introspect(.textEditor, on: .iOS(.v16, .v17)) { textView in self.textView = textView } #endif } } import SwiftUI @_spi(Advanced) import SwiftUIIntrospect #if os(macOS) import AppKit public typealias TextView = NSTextView public typealias TextViewDelegate = NSTextViewDelegate #elseif os(iOS) import UIKit public typealias TextView = UITextView public typealias TextViewDelegate = UITextViewDelegate #endif
  20. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect #if os(macOS) import AppKit public

    typealias TextView = NSTextView public typealias TextViewDelegate = NSTextViewDelegate #elseif os(iOS) import UIKit public typealias TextView = UITextView public typealias TextViewDelegate = UITextViewDelegate #endif struct EditorView: View { @State var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() #if os(macOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } #elseif os(iOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard textView.markedTextRange == nil else { return true } if text == "\n" { print("Enterが入力されたよ") } return true } } #endif var body: some View { TextEditor(text: $text) #if os(macOS) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } #elseif os(iOS) .introspect(.textEditor, on: .iOS(.v16, .v17)) { textView in self.textView = textView } #endif } } #if os(macOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } #elseif os(iOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard textView.markedTextRange == nil else { return true } if text == "\n" { print("Enterが入力されたよ") } return true } } #endif
  21. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect #if os(macOS) import AppKit public

    typealias TextView = NSTextView public typealias TextViewDelegate = NSTextViewDelegate #elseif os(iOS) import UIKit public typealias TextView = UITextView public typealias TextViewDelegate = UITextViewDelegate #endif struct EditorView: View { @State var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() #if os(macOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } #elseif os(iOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard textView.markedTextRange == nil else { return true } if text == "\n" { print("Enterが入力されたよ") } return true } } #endif var body: some View { TextEditor(text: $text) #if os(macOS) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } #elseif os(iOS) .introspect(.textEditor, on: .iOS(.v16, .v17)) { textView in self.textView = textView } #endif } } var body: some View { TextEditor(text: $text) #if os(macOS) .introspect( .textEditor, on: .macOS(.v13, .v14) ) { textView in self.textView = textView } #elseif os(iOS) .introspect( .textEditor, on: .iOS(.v16, .v17) ) { textView in self.textView = textView } #endif }
  22. import SwiftUI @_spi(Advanced) import SwiftUIIntrospect #if os(macOS) import AppKit public

    typealias TextView = NSTextView public typealias TextViewDelegate = NSTextViewDelegate #elseif os(iOS) import UIKit public typealias TextView = UITextView public typealias TextViewDelegate = UITextViewDelegate #endif struct EditorView: View { @State var text: String = "" @Weak var textView: TextView? { didSet { textView?.delegate = delegate } } var delegate = Delegate() #if os(macOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { guard !textView.hasMarkedText() else { return true } if replacementString == "\n" { print("Enterが入力されたよ") } return true } } #elseif os(iOS) final class Delegate: NSObject, TextViewDelegate { func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { guard textView.markedTextRange == nil else { return true } if text == "\n" { print("Enterが入力されたよ") } return true } } #endif var body: some View { TextEditor(text: $text) #if os(macOS) .introspect(.textEditor, on: .macOS(.v13, .v14)) { textView in self.textView = textView } #elseif os(iOS) .introspect(.textEditor, on: .iOS(.v16, .v17)) { textView in self.textView = textView } #endif } } ͚ͬ͜͏େม͚ͩͲ iOS, macOS྆ରԠͯ͠ SwiftUIͰ࣮૷Ͱ͖Δ
  23. @Weak var textView: TextView? { didSet { textView?.delegate = delegate

    } } -略- var body: some View { TextEditor(text: $text) .introspect( .textEditor, on: .iOS(.v16, .v17) ) { textView in self.textView = textView } .onChange(of: text, { print(text) // 呼ばれない }) } @Weak var textView: TextView? { didSet { //textView?.delegate = delegate } } -略- var body: some View { TextEditor(text: $text) .introspect( .textEditor, on: .iOS(.v16, .v17) ) { textView in self.textView = textView } .onChange(of: text, { print(text) // 呼ばれる }) }
  24. TextEditor( text: viewStore.binding( get: { $0.text }, send: { .someAction

    // 呼ばれない }() ) ) TCAͰbinding͍ͯ͠Δ৔߹
  25. ·ͱΊ • ม׵࣌ʹจࣈ͕ӅΕͯ͠·͏ͷͰmacOS SDKͰϏϧυ͠Α͏ • swiftui-introspectΛ࢖ͬͯUITextView,NSTextViewΛऔಘ͠Α͏ ‣ UIViewRepresentable, NSViewRepresentableͱ͍͏ख΋͋ΔΑ •

    swiftui-introspectͰdelegateΛ্ॻ͖͢ΔͱมߋΠϕϯτ͕ඈΜͰ͜ͳ͍ͷͰ஫ҙ • Ͳͷํ਑Ͱ΋େมͳͷͰؤுΖ͏ ‣ ϩδοΫΛ෼཭Ͱ͖Δʢ͠΍͍͢ʣΞʔΩςΫνϟ΍ઃܭΛ͠·͠ΐ͏
  26. ࢀߟɿݸਓ։ൃΞϓϦMemoliaͰ͸Ͳ͏͢Δ͔ʁ • SwiftUI + swiftui-introspectΛ࢖͍ͬͯΔ ‣ কདྷతʹSwiftUI.TextEditor͕֦ு͞ΕΔͱ৴ͯ͡ • onChange͕ൃՐ͠ͳ͍ͷ͸ٯʹOKͱߟ͍͑ͯΔ ‣

    ૒ํ޲όΠϯσΟϯά͕ࢮ͵ͷͰTCAͰ୯ҰσʔλϑϩʔʹͰ͖Δ • swiftui-introspectܦ༝Ͱ͸ΠϕϯτൃՐ͚ͩͤͯ͞ϩδοΫ͸શ෦෼཭ ‣ TextEditor͕֦ு͞ΕͨΒΠϕϯτൃՐݩΛม͑Ε͹͍͍͚ͩʢͷ͸ͣʣ