D3.js 를 사용하여 데이터 시각화하기 #8 그룹바 차트 만들기
NSViewRepresentable을 사용하여 SwiftUI 기반 앱에서 AppKit 사용하기
2024-04-06
Explanation
안녕하세요! 벌써 4월이네요.
저는 요즘 눈누난나 macOS의 애플리케이션을 만들고 있는데요. 작년 12월부터 Swift에 대해 조금씩 공부하다가, 올해 1월부터 본격적으로 만들기 시작했답니다! 원래 생각은 3월까지 초기 버전을 개발해서 배포까지 하는 생각이었는데, 생각보다 관련한 이것저것의 러닝커브가 커서 아직도 한창 개발중이네요..
(대략.. 그동안 포스팅하지 않은 변명..)
원래, macOS App 프로젝트 말고도 해보고 싶던 하려고 했던 것들이 많은데 생각했던 3월이 지나기도 했고 해서, 이제 조금씩 이것저것 같이 하려고 해요. (아마도) 이제 블로그 포스팅도 다시 조금씩 하려고 합니다!!
그리고 아무래도 최근에 계속 해온 게 macOS App 개발이어서 당분간은 Swift 관련 정보를 포스팅하게 될 것 같습니다!
오늘은 오랜만에 포스팅이라 그런지(열심히 포스팅 안해서 찔렸는지..) 서론이 길었네요.
SwiftUI는 아무래도 최근에? 만들어진? 만들어지고 있는? 프레임워크라 그런지 아주아주 직관적이고 생각보다 러닝커브가 크지 않았어요. (오묘히 React와 비슷하게 생겨서 더 편했는지도 모르겠습니다.)
하지만 개발 기간이 얼마 되지 않은 만큼, 지원하지 않는 부분이 많아서 아주아주 간단한 프로젝트가 아니면 SwiftUI만 사용해서 온전한 서비스를 만드는 건 아직 어려울 것 같았답니다. 그래도 SwiftUI에서는 AppKit의 요소를 결합하여 사용할 수 있도록 지원하고 있는데요.
그래서 오늘은 SwiftUI에서 NSViewRepresentable 프로토콜을 사용해서 AppKit의 요소를 결합하여 사용하는 방법에 대해 적어보려 합니다.
AppKit이라는 단어가 익숙하지 않을 수 있는데요. 대충, 애플의 서비스 개발에 사용되는 Cocoa Touch 프레임워크 기반으로 iOS 개발에는 UIKit이 사용되고 macOS 개발에는 AppKit이 사용된다고 생각하시면 될 것 같아요. 아무래도 iOS 앱 개발 비중이 많다보니 AppKit보다는 UIKit에 대한 정보가 많은데요. 저는 macOS 개발을 하고 있어서 작성되는 모든 글은 AppKit을 기반으로 합니다!
앞서 이야기 했듯, 아직 공부를 한지 몇개월 되지 않아서 글에 잘못된 부분이 있을 수 있습니다!!
그리고 작성된 모든 코드는 https://github.com/falsy/blog-post-example/tree/master/macOS-project/macOSProject에서 확인하실 수 있습니다.
너무 무작정 작성하면 좀 어려우니까, 특정 상황을 가정해 볼게요. 예를 들어서,
SwiftUI의 TextField는 값이 입력되었을때 onChange 메서드를 체이닝해서 값의 변화에 따라 제어할 수 있는데요. 그런데 키가 입력되었을때, 값이 변하기전 시점에서의 제어는 할 수가 없답니다.
그래서 오늘 예시로 작성해 볼 코드는 AppKit의 NSTextField를 SwiftUI에 결합해서 textField에 백스페이스가 입력되었을때 특정 조건에서 이를 무시할 수 있는 코드를 만들어보겠습니다!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
import SwiftUI struct CustomTextField: NSViewRepresentable { @Binding var isDisableBackSpace: Bool class Coordinator: NSObject, NSTextFieldDelegate { var parent: CustomTextField init(_ parent: CustomTextField) { self.parent = parent } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.deleteBackward(_:)) { if self.parent.isDisableBackSpace { return true } } return false } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSTextField { let textField = NSTextField() textField.delegate = context.coordinator return textField } func updateNSView(_ nsView: NSTextField, context: Context) { } } |
‘isDisableBackSpace’ 는 ‘CustomTextField’가 사용 될 곳에서 @State로 선언해서 파라미터로 전달할 거에요.
그리고 Coordinator에서 control 메서드를 사용해서 ‘deleteBackward'(백스페이스)키가 입력되었을때, isDisableBackSpace가 ‘true’ 일때, 동작을 무시하도록 추가했어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import SwiftUI struct TextFieldView: View { @State var isDisableBackSpace: Bool = false var body: some View { VStack { Text(isDisableBackSpace ? "백스페이스 비활성화 상태" : "백스페이스 활성화 상태") CustomTextField(isDisableBackSpace: $isDisableBackSpace) .padding(10) Button { isDisableBackSpace.toggle() } label: { Text(isDisableBackSpace ? "활성화" : "비활성화") } } } } |
짜잔!
이렇게 구성하면 ‘isDisableBackSpace’의 값에 따라 TextField의 백스페이스의 키 입력이 적용되기 전에 적용을 제어할 수 있답니다.
개발을 하다보면, NSTextField 말고도 NSView가 필요할 때도 있는데요. 예를 들자면 SwiftUI의 드래그 앤 드롭은 아직 기능적으로 부족한 면이 많아서 NSView의 드래그 앤 드롭 이벤트가 필요하다거나? 등등..
그런데 아무래도 AppKit의 뷰 요소들은 SwiftUI에 비해서 뷰 요소들을 구성하고 스타일링을 하는데 어렵? 불편? 한데요. 그래서 NSViewRepresentable를 사용해서 SwiftUI에 AppKit의 요소를 추가한 것 처럼 NSViewRepresentable에서는 AppKit 요소안에 SwiftUI를 HostingView를 사용해서 추가할 수 있답니다.
그래서 이번에는, SwiftUI -> AppKit -> SwiftUI 이렇게 뷰를 구성하는 방법을 간단하게 정리해 볼게요.
1 2 3 4 5 6 7 8 9 10 11 12 |
import SwiftUI struct HostingContentView: View { var body: some View { HStack { Text("Hosting Content View") } .frame(maxWidth: 200) .padding(10) .background(.red) } } |
SwiftUI -> AppKit -> “SwiftUI” 여기에 사용될 SwiftUI의 View를 만들어주고,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import SwiftUI struct CustomHostingView: NSViewRepresentable { func makeNSView(context: Context) -> NSView { let containerView = NSView() let hostingView = NSHostingView(rootView: HostingContentView()) hostingView.translatesAutoresizingMaskIntoConstraints = false containerView.addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), hostingView.heightAnchor.constraint(equalTo: containerView.heightAnchor) ]) return containerView } func updateNSView(_ nsView: NSView, context: Context) { } } |
SwiftUI -> “AppKit” -> SwiftUI 여기에 해당하는 코드입니다.
보시면, makeNSView에서 NSView를 선언하고 NSHostingView로 SwiftUI의 뷰를 선언하고 addSubview를 통해 NSView에 추가한 걸 볼 수 있습니다. 그리고
1 2 3 4 5 6 7 8 9 10 11 12 13 |
... hostingView.translatesAutoresizingMaskIntoConstraints = false ... NSLayoutConstraint.activate([ hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), hostingView.heightAnchor.constraint(equalTo: containerView.heightAnchor) ]) ... |
제가 아직 지식이 부족해서 정확하게 설명하기는 어려운데요..
“hostingView.translatesAutoresizingMaskIntoConstraints = true”로 설정이 되어있으면,
HostringView로 추가한 View는 AppKit의 그 위치나 영역을 고정된 프레임을 기준으로 레이아웃이 결정되어서 생각한 것처럼 동작하지 않더라고요?
그래서 “hostingView.translatesAutoresizingMaskIntoConstraints = false”로 Auto Layout을 설정해주고, NSLayoutConstraint.activate 를 호출해서 hostingView의 위치나 영역이 containerView와 같게 동작하도록 해주면 뷰가 제가 생각한 것처럼(SwiftUI처럼?) 동작하게 된답니다.
1 2 3 4 5 6 7 8 9 |
import SwiftUI struct HostingView: View { var body: some View { VStack { CustomHostingView() } } } |
마지막으로 “SwiftUI” -> AppKit -> SwiftUI 여기에 해당하는 코드입니다.
짜잔!
(사실 늘 그래왔지만..) 아직 잘 모르는 내용으로 포스팅하려니, 어렵네요.
macOS 애플리케이션 개발에 관심이 있는 분에게 조금이라도 도움이 되길 바라며..
조만간 다음 글로 찾아오겠습니다!!