스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
AppKit과 함께 SwiftUI 사용
단축어 앱에서 SwiftUI와 AppKit을 모두 사용하여 macOS에서 최고 수준의 경험을 만드는 방법을 확인하세요. 단축어 팀에 참여하여 AppKit 코드에서 SwiftUI 보기를 호스팅하고, 레이아웃 및 크기 조정을 처리하며, 응답자 체인에 참여하고, 탐색 포커스를 활성화하는 방법 등에 대해 알아보시기 바랍니다. 또한 AppKit 보기를 호스팅하여 기존 코드를 앱 내부의 SwiftUI 레이아웃으로 마이그레이션하는 방법을 보여드리겠습니다.
리소스
관련 비디오
WWDC22
WWDC21
-
다운로드
♪ 부드러운 힙합 음악 ♪ ♪ 'AppKit에서 SwiftUI 사용하기'에 잘 오셨어요 저는 단축어 팀에서 일하는 엔지니어 Ian입니다 macOS Monterey에서 단축어가 macOS에 들어갔어요 단축어는 Mac에서 SwiftUI를 많이 사용하죠 SwiftUI는 플랫폼 환경의 맞춤 설정을 도와줘요 그러면서도 iOS와 watchOS의 일반적인 화면을 공유할 수 있죠 이 영상에서 저는 Mac 앱에 SwiftUI를 적용하는 첫 단계를 보여 드릴 거예요 단축어 앱의 몇 가지 예시를 통해서요 우선 여러분의 앱에서 SwiftUI 뷰를 호스팅하는 예시를 보여 드릴게요 그리고 AppKit과 SwiftUI 사이에서 데이터를 전달하는 방법을 설명해 드리겠습니다 또 컬렉션 뷰나 테이블 뷰의 셀에서 SwiftUI 뷰를 호스팅하는 법도 다루고 AppKit에 안에 들어간 SwiftUI 뷰의 레이아웃과 크기를 조절하는 방법과 여러분의 SwiftUI 뷰를 응답자 체인에 참여시키고 포커스가 가능해지는 방법 끝으로 SwiftUI에서 AppKit 뷰를 호스팅하는 법까지 알아볼게요 자, AppKit에서 SwiftUI를 호스팅하는 법부터 시작할까요? 단축어 창은 메인 윈도에 AppKit 분할 뷰 컨트롤러가 있고 SwiftUI로 만들어진 사이드 바가 왼쪽에 있습니다 사이드바 뷰는 SwiftUI List를 구현했는데요 앱에서 탐색할 수 있는 모든 위치가 목록의 행으로 표시됩니다 뷰는 선택 항목 바인딩을 통해 선택 항목을 추적합니다 선택할 수 있는 항목들은 SidebarItem 타입의 케이스로 표현합니다 이 경우엔 이미 분할 뷰 컨트롤러가 있으므로 사이드바 뷰를 호스팅하기 위해 NSHostingController라는 클래스를 SwiftUI에서 사용합니다 SwiftUI 사이드바 뷰가 해당 호스팅 컨트롤러의 루트 뷰로 전달되죠 호스팅 컨트롤러는 다른 뷰 컨트롤러처럼 사용할 수 있기 때문에 여기서는 splitViewItem을 구성하고 스플릿 뷰 컨트롤러에 추가합니다 이제 사이드바가 분할 화면에 자리를 잡았지만 제대로 작동하려면 선택을 바꿨을 때 분할 화면의 오른쪽에 다른 페이지가 나와야 합니다 현재 선택된 항목의 상태는 SwiftUI 내에서만 존재하죠 우리는 그 선택 항목을 분할 화면과 사이드바가 공유할 수 있는 위치로 옮겨야 합니다 좋은 방법은 모델 객체를 만들고 SwiftUI 외부에 저장되는 모델 객체를 만들고 공유해야 하는 상태를 포함하는 겁니다 이 객체를 SelectionModel이라고 할게요 사이드바는 여전히 SelectionModel에서 상태를 읽고 쓸 수 있습니다 코드에서 SelectionModel은 ObservableObject 프로토콜을 준수하는 클래스입니다 이런 객체가 되면 모델의 저장 상태가 변경됐을 때 SwiftUI는 뷰를 다시 로딩합니다 여기엔 현재 선택된 사이드바 항목이 저장되죠 Published 속성 래퍼 덕분에 선택 항목이 변경되면 SwiftUI 사이드바 뷰를 업데이트할 수 있습니다 누군가 사이드바의 선택 항목을 변경하면 모델은 세부 화면에 새 페이지를 표시할 수 있죠 이제 AppKit에서 SwiftUI를 호스팅하는 법을 알았습니다 이제 컬렉션과 테이블의 셀로 넘어가죠 다른 플랫폼의 단축어를 macOS로 불러오면 이미 단축어 화면이 아이콘으로 이루어진 SwiftUI 뷰로 나옵니다 컬렉션 뷰나 홈 화면 위젯으로 만들어져 있죠 macOS에선 같은 화면이 NSCollectionView의 셀에 표시됩니다 항목이 많은 컬렉션 뷰나 테이블 뷰에서는 각 셀의 화면이 스크롤하면서 다시 사용되고 시간이 지나며 다른 콘텐츠가 나옵니다 셀의 재사용을 확실히 수행하려면 사용자가 스크롤할 때 셀에서 하위 뷰를 더하거나 제거하지 않도록 해야 합니다 SwiftUI 뷰를 각 셀에 표시할 때는 단일 호스팅 뷰를 사용하고 셀의 내용을 바꿔야 할 때는 다른 루트 뷰로 업데이트하세요 컬렉션 뷰 셀에서 SwiftUI 뷰를 호스팅하는 데 필요한 건 이게 다예요 여기 예시에서 전 단축어 뷰를 표시하는 셀을 만들고 있습니다 각각의 셀엔 SwiftUI를 호스팅하는 NSHostingView가 포함돼 있죠 셀은 내용을 구성하기 전에 생성되기 때문에 아무것도 없는 상태로 시작하고 단축어를 표시할 준비가 됐을 때 처음으로 설정될 겁니다 displayShortcut 메서드는 데이터 원본에서 셀이 단축어를 표시하도록 구성될 때 호출됩니다 이 메서드는 SwiftUI ShortcutView를 만들죠 이미 호스팅 뷰가 있는 경우엔 그 호스팅 뷰의 루트 뷰가 새로운 뷰로 설정됩니다 하지만 처음이라면 새 호스팅 뷰가 만들어지고 셀의 하위 뷰처럼 추가됩니다 SwiftUI를 호스팅하는 셀의 생애 주기를 알려 드릴게요 우선 셀이 초기화되고 아직 표시할 단축어가 없어 하위 뷰가 없는 상태로 시작합니다 단축어 표시 메서드가 처음으로 호출되면 표시해야 할 단축어 뷰로 호스팅 뷰가 생성되죠 이게 SwiftUI의 뷰 계층 구조를 만듭니다 V스택과 이미지, 공백 텍스트 화면을 두 개 포함했죠 화면을 스크롤해서 이 셀이 밖으로 나가면 시스템에 의해 잠재적으로 대기열이 해제되어 다른 단축어를 표시해야 합니다 이때 새로운 단축어 뷰가 생성되고 호스팅 뷰에 보여집니다 호스팅 뷰에 원래 다른 단축어가 표시되고 있었기 때문에 V스택과 공백을 포함한 뷰의 전체 구조를 재사용하고 바뀐 이미지, 텍스트 배경만 업데이트합니다 자, 다음으로 레이아웃과 사이즈 조정에 대해 알아보죠 호스팅 컨트롤러와 호스팅 뷰는 고유한 크기가 있는데요 SwiftUI 뷰의 이상적인 너비와 높이를 기반으로 합니다 SwiftUI는 자동으로 레이아웃 제한 조건을 생성해 업데이트하고 AppKit 레이아웃 시스템에선 이 조건에 따라 뷰의 크기를 적절히 조절합니다 뷰는 유연한 점도 있습니다 최소와 최대 길이 사이에서 다양한 크기를 지원하죠 SwiftUI는 여기에 대한 제한 조건도 만듭니다 SwiftUI 호스팅 뷰가 계층 구조에 들어갈 땐 자동 레이아웃 제한 조건을 상위 뷰나 다른 인접 뷰에 적용해야 하죠 프레임 변경자나 다른 SwiftUI 레이아웃을 사용하면 그때 만들어진 제한 조건이 업데이트됩니다 너비를 고정된 길이로 다시 정의하는 식이죠 창의 크기는 사용자가 조절할 수 있어서 최소 크기와 최대 크기가 있습니다 호스팅 뷰가 창의 최상위 콘텐츠 뷰로 설정되면 SwiftUI는 자동으로 표시되는 내용에 따라 창의 최소, 최대 크기를 업데이트합니다 즉 콘텐츠에 따라서 창의 크기를 수직 방향 수평 방향 혹은 양쪽으로 조절할 수 있게 되죠 호스팅 컨트롤러에 위치한 SwiftUI 뷰도 모달 윈도로 나올 때는 콘텐츠에 따라 크기가 조정됩니다 예를 들어 AppKit의 팝오버에 SwiftUI 뷰를 쉽게 배치할 수 있는데요 여기에 보이는 것처럼 NSViewController의 팝오버 표시 API를 사용하는 호스팅 컨트롤러를 표시하면 되죠 presentAsSheet 메서드를 이용해서 SwiftUI를 시트로 표시할 수도 있어요 마지막으로 모달 윈도의 경우 presentAsModalWindow 메서드로 닫힐 때까지 조작을 차단하는 창을 표시할 수 있습니다 창의 크기는 내용에 맞게 조정되죠 macOS Ventura에는 NSHostingView와 NSHostingController에 새로운 API가 있습니다 자동으로 추가되는 제한 조건을 사용자가 정의할 수 있는 기능이죠 기본적으로 호스팅 컨트롤러와 뷰는 최소 크기, 고유 크기 및 최대 크기에 대해 제한 조건을 생성합니다 뷰의 크기를 항상 유연하게 조정하고 싶거나 AppKit에서 주변 뷰의 제한 조건이 이미 추가돼 있다면 성능상의 이유로 이런 제한 조건 중에 일부를 사용하지 않게 설정할 수 있습니다 호스팅 컨트롤러의 경우 뷰의 이상적인 크기에 따라 권장 콘텐츠 크기를 정하려면 preferredContentSize 옵션을 활성화할 수 있어요 여러분의 앱에 SwiftUI 뷰를 추가할 때 다른 뷰들처럼 응답자 체인과 포커스 시스템에 참여하는 게 중요해요 단축어에서는 편집기가 SwiftUI 뷰로 구현됐어요 하지만 편집기는 메뉴 바의 명령을 처리해야 하죠 메뉴 바는 AppKit으로 구현됐어요 이 명령에는 잘라내기, 복사 붙여넣기 등이 있어요 또 사용자 지정 메뉴 항목도 몇 가지 구현했어요 위로 이동과 아래로 이동이죠 AppKit에서 뷰 계층 구조는 뷰의 사슬을 만드는 데 이걸 응답자 체인이라고 해요 표적이 된 응답자를 최초 응답자라고 하죠 메뉴 항목을 선택하면 해당 항목의 선택 장치가 최초 응답자에게 전송돼요 하지만 최초 응답자가 해당 선택 장치에 응답하지 않으면 선택 장치가 다음 응답자에게 차례차례 전송하죠 뭔가가 선택 장치를 처리하거나 앱에 도달할 때까지요 SwiftUI에서 최초 응답자와 동등한 게 포커스드 뷰예요 포커스 가능한 SwiftUI 뷰는 키보드 입력에 응답하고 응답자 체인에 전송된 선택 장치를 처리할 수 있죠 텍스트 필드 같은 뷰는 포커스가 이미 가능하지만 포커스 변경자를 사용하면 다른 뷰도 포커스 가능 뷰로 바꿀 수 있습니다 SwiftUI에는 일반 명령을 처리하는 몇 가지 변경자가 있습니다 복사, 붙여넣기 잘라내기 같은 명령이죠 이런 명령은 임시 보드의 안과 밖으로 값을 전달하고 사용자가 앱 안팎으로 데이터를 전송할 수 있는 쉬운 방법입니다 단축어 편집기는 수정자 중에 onMoveCommand와 onExitCommand를 이용하여 화살표 키와 Esc 키를 처리합니다 onCommand 변경자는 AppKit의 일반 선택 장치나 사용자 지정 선택 장치를 처리하는 데 사용할 수 있습니다 여기서 우리는 AppKit의 전체 선택 명령어와 단축어 앱 내에 정의된 위로 이동, 아래로 이동 명령어를 처리합니다 앱에서 포커스 및 키보드 탐색을 테스트할 땐 키보드 시스템 설정을 열고 전체 키보드 탐색을 켜고 끈 상태에서 모두 테스트해야 합니다 여러 제어는 활성화된 상태에서만 포커스가 가능하기 때문이죠 키보드로 앱의 작동 범위를 높이는 데는 더 많은 방법이 있습니다 예를 들어 FocusState와 포커스드 모디파이어라는 API를 통해 어떤 뷰가 포커스되는지 프로그래밍으로 바꿀 수 있죠 키보드와 포커스에 대해 자세히 알고 싶으면 다른 영상을 추천할게요 'SwiftUI에서 포커스의 지시 및 반영'을 보세요 끝으로 SwiftUI에서 AppKit 뷰를 호스팅하는 법을 알아보죠 단축어가 SwiftUI 레이아웃 내에서 AppKit 뷰를 호스팅하는 경우가 몇 가지 있습니다 또 SwiftUI 앱을 만든다면 AppKit 뷰를 호스팅해야 할 수도 있겠죠 한 예시가 SwiftUI 편집기의 내부에 있습니다 여기엔 AppleScript 편집기 뷰가 내장되어 있는데요 macOS의 몇몇 시스템 앱과 공유하는 AppKit 컨트롤이죠 SwiftUI는 두 가지 표현 가능 프로토콜을 제공해 AppKit 뷰와 뷰 컨트롤러가 SwiftUI의 뷰 계층 구조에 포함될 수 있게 합니다 표현 가능 프로토콜은 SwiftUI 뷰처럼 AppKit 뷰를 만들고 업데이트하는 법을 묘사하는 거죠 AppKit의 많은 클래스는 대리자, 관찰자가 있거나 키 값 옵저빙 혹은 관찰해야 할 알림에 의존하므로 프로토콜에는 뷰와 뷰 컨트롤러와 함께 구현될 수 있는 코디네이터 객체도 선택에 따라 포함됩니다 호스트된 객체와 해당 코디네이터의 생애 주기를 알려 드리죠 호스트된 뷰의 초기화로 시작합니다 뷰가 처음으로 표시될 때 일어나는 일이죠 초기화 중에 SwiftUI가 처음 하는 일은 코디네이터를 만드는 겁니다 위임 혹은 상태 관리를 위해 필요하다면 선택 사항이지만 makeCoordinator에서 자신의 타입을 정의하고 또 반환할 수 있습니다 코디네이터의 단일 인스턴스는 뷰의 수명이 끝날 때까지 지속됩니다 두 번째로 makeNSView나 makeNSViewController 메서드가 호출됩니다 여기서 SwiftUI에 뷰의 새 인스턴스를 만드는 법을 설명합니다 컨텍스트에 방금 만든 코디네이터가 포함되므로 코디네이터가 만약 있다면 여기에서 뷰의 대리자나 다른 유형의 관찰자로 지정하는 게 좋겠죠 뷰가 만들어지고 나면 SwiftUI의 상태나 환경이 바뀔 때마다 업데이트 뷰 메서드가 호출될 겁니다 여기서 AppKit 뷰에 저장된 속성이나 상태를 업데이트해서 주변의 SwiftUI 상태 및 환경과 동기화를 유지해야 하죠 업데이트 메서드가 자주 호출될 수 있기 때문에 뷰의 변경 사항을 최소화해야 합니다 변경된 부분이 있을 때만 무엇이 변경됐는지 확인하고 뷰에서 영향을 받는 부분을 다시 로딩해야 하죠 SwiftUI가 호스트된 뷰의 표시를 마치면 뷰는 해체됩니다 호스트된 뷰와 코디네이터에 모두 할당이 취소되죠 할당이 취소되기 전에 표현 가능 프로토콜은 필요한 경우 상태를 정리할 수 있는 구현 방법을 제공합니다 자, 이제 생애 주기를 알았고 표현 가능 프로토콜에 익숙해졌으니 단축어 앱에서 사용자 지정 스크립트 편집기 뷰를 호스팅하는 법을 보여 드릴게요 스크립트 편집기는 ScriptEditorView라는 NSView예요 편집기에 작성된 코드는 sourceCode 속성을 통해 접근 및 수정할 수 있고 뷰를 비활성화하여 변경을 방지할 수 있죠 스크립트 편집기에도 대리자가 있고 누군가 소스 코드를 수정할 때마다 알림이 옵니다 AppKit 뷰를 호스팅할 때 먼저 SwiftUI에서 뷰가 어디에 배치될지 그리고 어떤 데이터를 주고받아야 할지 고려해 보세요 단축어 앱에서 이 뷰는 컴파일 버튼 옆에 있는 컨테이너 뷰에 배치됩니다 컴파일 버튼의 처리기는 뷰에 입력된 소스 코드에 접근해야 하죠 소스 코드는 상태 속성 래퍼를 이용해 SwiftUI에 저장됩니다 표현 가능 프로토콜을 준수하므로 이 상태에서 읽고 쓸 수 있죠 표현이 가능하게 하려면 NSViewRepresentable을 준수하는 유형부터 만듭니다 이게 NSView를 호스팅하게 될 테니까요 SwiftUI에서 설정 가능한 각 항목에 대해 속성을 추가하세요 소스 코드에는 바인딩이 사용되고 SwiftUI에 저장된 상태를 읽고 쓰게 됩니다 구현해야 할 첫 번째 메서드는 makeNSView입니다 바로 여기에서 뷰의 새 인스턴스를 만드는 방법을 설명하고 필요한 일회성 설정을 해야 합니다 여기에서 대기자는 코디네이터로 설정합니다 코디네이터는 잠시 후에 더 얘기해 드릴게요 다음은 updateNSView를 구현합니다 이 메서드는 소스 코드가 변경되거나 SwiftUI 환경이 바뀌면 호출됩니다 sourceCode 속성이 설정되면 스크립트 편집기는 많은 작업을 수행하기 때문에 불필요한 작업을 피하기 위해 미리 뷰의 값을 비교하고 변경되는 경우에만 속성을 설정합니다 updateNSView로 전달된 컨텍스트에는 SwiftUI 환경이 포함되어 있죠 환경 값인 isEnabled가 스크립트 편집기의 isEditable 속성에 전달돼서 나머지 SwiftUI 뷰의 계층 구조가 있다면 편집이 비활성화됩니다 누군가 뷰의 소스 코드를 수정하면 소스 코드의 바인딩이 새 값을 파악해야 합니다 그러려면 ScriptEditorViewDelegate를 준수하는 코디네이터를 구현해야 합니다 코디네이터는 표현 가능한 값을 저장할 겁니다 거기엔 업데이트해야 하는 소스 코드 바인딩도 포함되어 있죠 sourceCodeDidChange 메서드를 통해 뷰의 새 문자열 값으로 바인딩이 설정됩니다 마지막으로 SwiftUI 대리자에게 코디네이터를 만들고 업데이트하는 법을 알려 줘야 하죠 우선 makeCoordinator란 메서드를 구현하여 새 코디네이터를 만들어야 합니다 코디네이터의 수명은 호스트된 뷰와 동일하고 호스트된 뷰와 마찬가지로 코디네이터에 추가한 속성은 표현 가능 속성이 변할 때마다 최신 상태를 유지해야 합니다 표현 가능 속성에 저장된 값이 변할 때 updateNSView가 호출되므로 여기서는 코디네이터의 표현 가능 속성이 업데이트됐네요 이제 SwiftUI에 AppKit을 추가하는 방법과 AppKit에 SwiftUI를 추가하는 방법을 배웠어요 지금부터 앱에 SwiftUI를 더해 보세요 사이드바 혹은 테이블 뷰나 컬렉션 뷰 셀에서 시작하는 게 좋을 거예요 뷰의 크기 조절이 제대로 되는지 일반 명령을 처리하고 포커스가 되는지 확인하세요 시간 내 주셔서 고마워요 여러분의 결과물이 기대되네요 ♪
-
-
1:29 - SidebarView and SidebarItem
struct SidebarView: View { @State private var selectedItem: SidebarItem var body: some View { List(selection: $selectedItem) { ... Section("Shortcuts") { ... } Section("Folders") { ... } } } } enum SidebarItem: Hashable { case gallery case allShortcuts ... case folder(Folder) }
-
1:53 - Hosting SwiftUI sidebar
let splitViewController = NSSplitViewController() let sidebar = NSHostingController(rootView: SidebarView(...)) let splitViewItem = NSSplitViewItem(viewController: sidebar) splitViewController.addSplitViewItem(splitViewItem)
-
3:06 - Sidebar selection model
class SelectionModel: ObservableObject { @Published var selectedItem: SidebarItem = .allShortcuts } // AppKit Window Controller cancellable = selectionModel.$selectedItem.sink { newItem in // update the NSSplitViewController detail }
-
4:37 - Collection view item hosting SwiftUI
class ShortcutItemView: NSCollectionViewItem { private var hostingView: NSHostingView<ShortcutView>? func displayShortcut(_ shortcut: Shortcut) { let shortcutView = ShortcutView(shortcut: shortcut) if let hostingView = hostingView { hostingView.rootView = shortcutView } else { let newHostingView = NSHostingView(rootView: shortcutView) view.addSubview(newHostingView) setupConstraints(for: newHostingView) self.hostingView = newHostingView } } }
-
7:55 - Popover presentation
viewController.present(NSHostingController(rootView: ...), asPopoverRelativeTo: rect, of: view, preferredEdge: .maxY, behavior: .transient)
-
8:15 - Sheet presentation
viewController.presentAsSheet(NSHostingController(rootView: ...))
-
8:22 - Modal window presentation
let hostingController = NSHostingController(rootView: ModalView()) hostingController.title = "Window Title" viewController.presentAsModalWindow(hostingController)
-
8:45 - Sizing options
hostingController.sizingOptions = [.minSize, .intrinsicContentSize, .maxSize]
-
10:47 - Copy, Cut, and Paste commands
Image(...) .focusable() .copyable { ... } .cuttable { ... } .pasteDestination(payloadType: Image.self) { ... }
-
11:02 - Respond to standard commands
struct ShortcutsEditorView: View { var body: some View { ScrollView { ... } .onMoveCommand { moveSelection(direction: $0) } .onExitCommand { cancelOperations() } .onCommand(#selector(NSResponder.selectAll(_:)) { selectAllActions() } .onCommand(#selector(moveActionUp(_:)) { moveSelectedAction(.up) } .onCommand(#selector(moveActionDown(_:)) { moveSelectedAction(.down) } } }
-
15:18 - Script editor
class ScriptEditorView: NSView { var sourceCode: String var isEditable: Bool weak var delegate: ScriptEditorViewDelegate? } protocol ScriptEditorViewDelegate: AnyObject { func sourceCodeDidChange(in view: ScriptEditorView) -> Void }
-
15:40 - Script editor container
struct ScriptEditorContainerView: View { @State var sourceCode: String = "" var body: some View { VStack { CompileButton { compile(code: sourceCode) } Divider() ScriptEditorRepresentable(sourceCode: $sourceCode) } } }
-
16:13 - Script editor representable
struct ScriptEditorRepresentable: NSViewRepresentable { @Binding var sourceCode: String func makeNSView(context: Context) -> ScriptEditorView { let scriptEditor = ScriptEditorView(frame: .zero) scriptEditor.delegate = context.coordinator return scriptEditor } func updateNSView(_ nsView: ScriptEditorView, context: Context) { if sourceCode != scriptEditor.sourceCode { scriptEditor.sourceCode = sourceCode } scriptEditor.isEditable = context.environment.isEnabled context.coordinator.representable = self } func makeCoordinator() -> Coordinator { Coordinator(representable: self) } } class Coordinator: NSObject, ScriptEditorViewDelegate { var representable: ScriptEditorRepresentable init(representable: ScriptEditorRepresentable) { ... } func sourceCodeDidChange(in view: ScriptEditorView) { representable.sourceCode = view.sourceCode } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.