PaletteEditor
에 viewmodifier
인 척하는 메서드를 달아서 close
버튼을 단 View
로 바꿔준다. import SwiftUI
struct PaletteChooser: View {
func body(for palette: Palette) -> some View {
HStack {
Text(palette.name)
ScrollingEmojisView(emojis: palette.emojis)
.font(emojiFont)
}
.popover(item: $paletteToEdit) { palette in
PaletteEditor(palette: $store.palettes[palette])
.wrappedInNavigationViewToMakeDismissable { paletteToEdit = nil }
}
}
}
Navigation View
로 감싸준 다음, toolbar
버튼을 달아줄거다!extension View {
@ViewBuilder
func wrappedInNavigationViewToMakeDismissable(_ dismiss: (() -> Void)?) -> some View {
if UIDevice.current.userInterfaceIdiom != .pad, let dismiss = dismiss {
NavigationView {
self
.navigationBarTitleDisplayMode(.inline)
.dismissable(dismiss)
}
.navigationViewStyle(StackNavigationViewStyle())
} else {
self
}
}
@ViewBuilder
func dismissable(_ dismiss: (() -> Void)?) -> some View {
if UIDevice.current.userInterfaceIdiom != .pad, let dismiss = dismiss {
self.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
}
} else {
self
}
}
}
짚고 넘어갈 포인트는 3갠데 먼저 인자로 받는 dismiss 클로저
를 옵셔널로 선언하면, 해당 기능이 불필요할 때는 그냥 nil
을 인자로 보낼 수 있다. 또 다른 side-effect
는 메서드 내부에서 옵셔널 언래핑을 해서 사용하면 @escaping
으로 선언하는 것을 피할 수 있다.
두 메서드 모두 if-else
문을 사용해 각 경우에 다른 결과를 리턴하므로 @ViewBuilder
라고 표시해줘야 한다.
wrappedNavigationViewToMakeDismissable(_:)
메서드에서 내비게이션 뷰 스타일을 .navigationViewStyle(StackNavigationViewStyle())
로 바꿔준 부분! 6주차 과제에서 아이패드특인줄 알았는데 가로 너비가 충분한 경우 디바이스에서 자동으로 splitView
를 적용한다고 한다. 따라서 stackView
스타일로 바꿔준다.
별 건 아니지만 ToolbarItem
의 placement
를 .cancellationAction
으로 지정했는데 이런 방식을 semantic placement
라고 한다. SwiftUI
가 placement
설정과 더불어 툴바의 상태, 현재 플랫폼 등에 따라 알아서 적절한 위치에 넣어준다.
참고로 PaletteManager
의 close
버튼도 위의 두 메서드를 사용해서 리팩토링할 수 있다! toolbar
가 중첩선언되는 거 아닌가 했는데 문제 없이 잘 적용된다
struct PaletteManager: View {
var body: some View {
NavigationView {
List {
// some code..
}
.navigationTitle("Manage Palettes")
.navigationBarTitleDisplayMode(.inline)
.dismissable { presentationMode.wrappedValue.dismiss() } // 여기 적용
.toolbar {
ToolbarItem { EditButton() }
}
}
}
}
undo/redo
버튼까지 총 4개가 들어가야 되는데 아이폰 세로모드의 경우 너비가 좁아서 버튼을 하나만 띄울 수 있다는 점이다...따라서 너비가 부족하면 contextMenu
가 뜨게 하고 충분한 경우 모든 버튼이 뜨도록하는 ViewModifier
를 만들어서 해결할거다!세로모드는 위처럼, 가로모드에는 아래처럼 뜨게 해줄 예정!
View
의 extension
으로 compactableToolbar(_:)
를 선언해줄건데, ViewBuilder
를 인자로 받아서 너비에 따라서 toolbar
에 contextMenu
혹은 버튼 전체가 나열된 View
를 반환해 줄 친구다 따라서 인자로 View
들을 받아야 하는데, View
는 인자 타입을 선언하는 데 쓸 수 없으므로 제네릭 Content
를 선언한 다음, View
프로토콜에 순응하게 한다. extension View {
func compactableToolbar<Content>(@ViewBuilder _ content: () -> Content) -> some View where Content: View {
self
.toolbar {
content()
.modifier(CompactableIntoContextMenu())
}
}
}
CompactableIntoContextMenu ViewModifier
는 현재 환경의 너비에 따라 content
가 담긴 contextMenu
혹은 content
그 자체를 반환한다. 여기서 현재 환경의 너비가 충분한지는 horizontalSizeClass
환경 프로퍼티를 이용하는데 .compact
또는 .regular
인지 알 수 있다. struct CompactableIntoContextMenu: ViewModifier {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
private var compact: Bool { horizontalSizeClass == .compact }
func body(content: Content) -> some View {
if compact {
Button {
} label: {
Image(systemName: "ellipsis.circle")
}
.contextMenu { content }
} else {
content
}
}
}
EmojiArtDocument
에 가서 적절한 위치에 compactableToolbar
를 달아주고 넣고 싶은 View
들을 클로저로 보내주면 끝~struct EmojiArtDocumentView: View {
var background: some View {
GeometryReader { geometry in
ZStack {
// some code..
}
.compactableToolbar {
AnimatedActionButton(title: "Paste Background", systemImage: "doc.on.clipboard", action: pasteBackground)
if Camera.isAvailable {
AnimatedActionButton(title: "Take Photo", systemImage: "camera") {
backgroundPicker = .camera
}
}
if PhotoLibrary.isAvailable {
AnimatedActionButton(title: "Search Photos", systemImage: "photo") {
backgroundPicker = .library
}
}
if let undoManager = undoManager {
if undoManager.canUndo {
AnimatedActionButton(title: undoManager.undoActionName, systemImage: "arrow.uturn.backward") {
undoManager.undo()
}
}
if undoManager.canRedo {
AnimatedActionButton(title: undoManager.undoActionName, systemImage: "arrow.uturn.forward") {
undoManager.redo()
}
}
}
}
}
}
}
AnimatedActionButton(title:systemImage:action)
를 다시 사용할거기 때문에 클립보드에서 복사한 사진을 배경 이미지로 설정해주는 함수를 만들어서 action
인자에 넣어주기만 하면 된다. UIPasteboard
인스턴스를 사용할건데, 그냥 단순히 다른 곳에서 복사한 이미지를 붙여넣을 것이므로 시스템 전체에서 공유하는 general
타입을 사용한다. 클립보드의 첫번째 아이템이 image
혹은 imageURL
이라면 setBackground()
메서드를 사용해 배경 이미지를 지정해주고, 클립보드에 아무것도 없는 경우 알림창을 띄우게 하면 끝!struct EmojiArtDocumentView {
private func pasteBackground() {
autoZoom = true
if let imageData = UIPasteboard.general.image?.jpegData(compressionQuality: 1.0) {
document.setBackground(.imageData(imageData), undoManager: undoManager)
} else if let url = UIPasteboard.general.url?.imageURL {
document.setBackground(.url(url), undoManager: undoManager)
} else {
alertToShow = IdentifiableAlert(
title: "Paste Background",
message: "There is no image currently on the pasteboard"
)
}
}
}
EmojiArt
앱에서 붙여넣기 버튼을 누르면 복사했던 이미지가 왼쪽 아래부터 슥 올라오는데, 이는 우리가 OptionalImage
를 Color.white
에 overlay
했기 때문! 따라서 제거해준다. struct EmojiArtDocumentView: View {
var background: some View {
GeometryReader { geometry in
ZStack {
Color.white // overlay 제거
OptionalImage(uiImage: document.backgroundImage)
.scaleEffect(zoomScale)
.position(convertFromEmojiCoordinates((0, 0), in: geometry))
.gesture(doubleTapToZoom(in: geometry.size))
}
}
}
}
UIKit
에서는 ViewModel
과 완전히 다른 controller
가 존재한다. controller
들은 UI
즉 View
들의 덩어리라고 생각할 수 있으며, 원래 자기가 할 일의 일부를 delegate
에게 넘긴다.
SwiftUI
와 UIKit
을 통합하기 위해서는 UIViewRepresentable
이나 UIViewControllerRepresentable
을 사용해서 UIKit
의 view
나 controller
를 SwiftUI View
로 바꿔줘야 한다.
UIViewRepresentable
과 UIViewControllerRepresentable
은 아래의 5가지 주요 컴포넌트를 갖는다.
UIKit
의 view/controller
를 생성하는 함수 func makeUIViewController(context: Context) -> controller
func makeUIView(context: Context) -> view
UIKit
요소를 업데이트하는 함수SwiftUI
의 핵심이 View
를 무효화하고 다시 그리는 것이므로 매우 중요delegate
을 의미하는 Coordinator
객체func makeCoordinator() -> Coordinater
delegate
가 알림 메세지를 받는다Coordinator
, SwiftUI environment
, animation transaction
등을 담고 있는 context
SwiftUI
의 다른 View
들과 상호작용하는 데 필수적view/controller
를 완전히 제거하는 함수func dismantleUIViewController(controller, coordinator: Coordinator)
SwiftUI
는 카메라 API가 없으므로 UIKit
의 API를 빌려와야 한다. 따라서 Camera
구조체를 선언한 다음, UIViewControllerRepresetable
프로토콜을 따르게 해서 UIKit
세계와 연결해준다.
Camera
구조체는 카메라 기능과 버튼을 제공하는 some View
다
1. UIViewControllerType 선언하기
UIViewControllerType
을 사진 찍는 데 필요한 시스템 인터페이스를 관리하는 UIImagePickerController
로 선언해준다. 이러면 이제 우리의 makeUIViewController(context:)
함수의 리턴값이 UIImagePickerController
가 된다!2. UIController 만들고, 업데이트 하기
UIViewController
를 만들기 위해서는 makeUIViewController(context:)
함수에서 UIImagePickerController
의 인스턴스를 선언한 다음, 유저가 사진을 찍으면서 줌인/아웃 할 수 있도록 allowsEditing
프로퍼티를 true
로 설정하고, delegate
프로퍼티에 우리가 선언한 Coordinator
클래스의 인스턴스를 할당해주면 된다. SwiftUI
를 업데이트할 필요가 없어서 updateUIViewController(_:context:)
는 빈 함수로 둔다struct Camera: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.allowsEditing = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {
// leave empty
}
}
3. Coordinator 만들기
makeCoordinator
함수는 Coordinator
를 리턴하는데 이 친구가 바로makeUIViewController(context:)
의 delegate
프로퍼티에 할당해 준 친구다, Coordinator
는 일종의 제네릭으로 우리가 구현해줘야 한다. 우리의 Coordinate
는 유저가 사진이나 영상을 pick
하거나 작업을 취소하는 경우 delegate
에게 알림을 보내고, picker
를 닫는 작업을 하는 UIImagePickerControllerDelegate
프로토콜을 따른다. 그 외에도 UIKit
세계의 친구들은 통상적으로 NSObject
클래스를 상속하며 UIImagePickerController
가 UINavigationControllerDelegate
프로토콜을 따르기 때문에 여기에도 순응한다.struct Camera: UIViewControllerRepresentable {
var handlePickedImage: (UIImage?) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(handlePickedImage: handlePickedImage)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
var handlePickedImage: (UIImage?) -> Void
init(handlePickedImage: @escaping (UIImage?) -> Void) {
self.handlePickedImage = handlePickedImage
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
handlePickedImage(nil)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
handlePickedImage((info[.editedImage] ?? info[.originalImage]) as? UIImage)
}
}
Coordinator
클래스에서 유저가 각각 작업을 취소했을 때와 무언가를 pick
했을 때 특정 액션을 수행하도록 선언해야 하는데, 이를 위해서 우리는 SwiftUI View
에서 어떻게 처리할 지 handlePickedImage: (UIImage?) -> Void 클로저
로 받아온 다음, 이를 사용해서 imagePickerControllerDidCancel(_:)
함수와 imagePickerController(_:info:)
에서 사용한다.handlePickedImage 클로저
는 SwiftUI View
-> Camera
-> Coordinator
의 과정으로 전달된다
유의할 점은 Picker
는 작업이 끝났다고 해서 자동으로 닫히지 않아 우리가 닫아줘야 하므로 handlePickedBackgroundImage(_:)
메서드에서 작업을 완료한 후 backgroundPicker = nil
로 설정해서 창을 닫아줘야 한다.
struct EmojiArtDocumentView: View {
private func handlePickedBackgroundImage(_ image: UIImage?) {
autozoom = true
if let imageData = image?.imageData {
document.setBackground(.imageData(imageData), undoManager: undoManager)
}
backgroundPicker = nil
}
}
PhotosUI
프레임워크를 불러와서 사용한다. PhotoLibrary
구조체를 선언해서 쓸건데, 전체적인 과정은 Camera
구조체를 만드는 것과 유사해서 세부사항만 살짝 정리하고 넘어갈거다! Picker
타입은 갤러리에서 사진/영상 등을 선택할 수 있는 인터페이스를 제공하는 PHPickerViewController
를 사용한다. UIImagePickerController
의 상위호환으로, 안정성과 신뢰성이 더 높고, 특정 유형의 자료만 디스플레이하는 등의 유용한 기능을 제공한다.
우리의 경우 유저가 사진만을 배경으로 설정할 수 있도록 하고 싶으므로 makeUIViewController(contex:)
함수에서PHPPickerViewController
설정에 관한 정보를 담고 있는 PHPPickerConfiguration
선언한 다음, filter
프로퍼티를 사용해서 사진만 나타내도록 설정한다.
Coordinator 클래스
에서 picker(_:didFinishPicking)
함수를 선언할 때, 유저가 선택한 항목을 NSItemprovider
가 전달하므로, 이를 loadObjects(ofType:)
함수를 선언해서 UIImage
로 변환한다.
import SwiftUI
import PhotosUI
struct PhotoLibrary: UIViewControllerRepresentable {
var handlePickedImage: (UIImage?) -> Void
static var isAvailable = true
func makeCoordinator() -> Coordinator {
Coordinator(handlePickedImage: handlePickedImage)
}
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration()
configuration.filter = .images
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// leave empty
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
var handlePickedImage: (UIImage?) -> Void
init(handlePickedImage: @escaping (UIImage?) -> Void) {
self.handlePickedImage = handlePickedImage
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
let found = results.map { $0.itemProvider }.loadObjects(ofType: UIImage.self) { [weak self] image in
self?.handlePickedImage(image)
}
if !found {
handlePickedImage(nil)
}
}
}
}
toolbar
에 그냥 AnimatedActionButton
을 선언하면 시뮬레이터의 경우 카메라가 없으므로 앱이 터진다. 따라서 Camera
에 isAvaible
프로퍼티를 선언해서 현재 사용중인 디바이스에 카메라가 있는지 확인하고 있는 경우에만 카메라를 토글하도록 한다. struct Camera: UIViewControllerRepresentable {
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
}
.sheet(item:content)
메서드를 사용할건데, 이 과정에서 BackgroundPickerType enum
을 선언하고 Identifiable
하게 만들어줘야 한다. 여기서의 팁은 var id: BackgroundPickerType { self }
와 같이 선언하면 자기 자신을 id
로 쓸 수 있다. rawValue
를 id
로 쓰기도 하지만, 해당 방법은 associated value
가 있는 경우 쓸 수 없으므로 앞에서 얘기한 방법이 더 좋다고 한다. enum BackgroundPickerType: Identifiable {
case camera
case library
var id: BackgroundPickerType { self }
}
Info.plist
- Privacy-Camera Usage Description
- The camera can be used to set the background of an emojiart document
@ViewBuilder
는 분기 등을 허용해서 다양한 child View
를 담을 수 있도록 해준다는 점을 다시 한 번 짚고 넘어갈 수 있었고, weak
에 대해서 다시 공부했는데 아직도 사실 좀 헷갈린다ㅠㅠㅠ 기본적으로 retain cycle
을 피하기 위해 쓴다는 건 알겠는데, 어떤 때에는 retain cycle
이 있는 것 같은데 weak
을 안 써도 해결되는 것 같고 헷갈린다...여기 다시 한번 해보기 언제쯤 확신을 갖고 이해할 수 있을까...
완강이 코앞이라 기뻐했는데 날짜를 보니 12월이라 바로 UIKit
공부를 시작해야될 것 같다는 생각이 들어서 슬펐다...대체 왜 벌써 12월이지? 아직도 할 게 산더미인데 나는 왜 이렇게 의지가 부족할까
close button for paletteEditor
set background in iPhone
creating toolbar for undo and paste
creating camera button
controllers are kinda bundles of UI
e.g. a UI that takes a picture from the camera all bundeld into one nice little space
Views are used but not as impt...controllers put the View together into one thing
Info.plist - Privacy-Camera Usage Description - "The camera can be used to set the background of an emojiart document"