Lecture 16: Multiplatform(macOS + iOS)

sun·2021년 11월 28일
1

유튜브 링크


Multiplatform 앱으로 바꿔주기 1 : 컴파일이 안되는데요

# Info.plist 와 Entitlements

  • EmojiArtmacOS 에서도 작동하도록 바꿔줄거다! 새 프로젝트를 multiplatform app 으로 선언하고, 기존 EmojiArt 의 파일들을 다 복붙해준다. Info.plist 도 업데이트해줘야 하는데 프로젝트 환경설정의 많은 부분이 플랫폼에 따라 상이하기 때문에 iOSMacOS 용이 각각 따로 생성되는 것을 확인할 수 있다. 디폴트로 있던 Document types , Exported Type Identifiers , Imported Type Identifiers 를 삭제해주고, 기존 EmojiArt 의 설정을 복붙한다. 이때, MacOS 에서는 Document typesRole 항목을 추가해서 값을 Editor 로 추가 설정하는 과정이 필요하다. 그리고 macOS 는 카메라가 없으므로 Privacy - Camera Usage DescriptioniOSInfo.plist 에만 추가한다.
    • CameraPhotoLibrary 파일은 iOS 폴더 밑으로 옮겨줄건데, 항상 파일의 빌드 타겟을 확인할 것...!

iOS

macOS

  • macOS 에서는 추가로 entitlement 를 설정해줘야 하는데, info.plist 에서의 privacy settings 의 강화된 버전이라고 생각하면 된다. 우리의 앱이 할 수 있는/없는 것들을 규정하는 보안 설정이다! 아무래도 맥은 운영체제가 더 복잡하고 한 번에 여러명의 유저가 있을 수 있기 때문에 더 신경 써 줄 부분이 많기 때문. 아래와 같은 항목을 추가하고 허용해줘야 나중에 인터넷에서 이미지를 드래그 & 드랍할 수 있다


# UIImage

  • UIImagemacOS 에는 없고 iOS 에만 있기 때문에 에러가 뜬다. 따라서 macOS 에서는 완전히 같지는 않지만 비슷한 역할을 하는 NSImage 로 대체해준다. 이때, macOS 용 파일을 따로 선언한 다음, typealias 를 써서 macOS 용 앱 빌드 시에는 UIImageNSImage 로 대체하게 해준다. 물론 UIImage 와 관련된 모든 코드가 해결되는 것은 아니므로 일부는 차후에 또 별도의 대책이 필요하지만 얼추 많은 에러가 해결된다!
// macOS
typealias UIImage = NSImage

# OptionalImage(uiImage:)

  • 배경 이미지를 불러온 다음 실제 배경으로 지정해 줄 때 커스텀 ViewOptionalImage(uiImage:) 를 사용하는데, 내부에서 Image(uiImage:) 를 사용하므로 위와 같은 이유로 문제가 된다.

  • macOS 에서는 Image(nsImage:) 를 사용하도록 해야되므로 Image extension 을 선언하는데, 여기서 멋진 건 NSImageUIImagetypealias 했기 때문에 인자를 UIImage 로 받아도 된다!

// macOS

extension Image {
    init(uiImage: UIImage) {
        self.init(nsImage: uiImage)
    }
}

# Editmode 미지원

  • macOS 는 우리가 PaletteManger 에서 팔레트를 삭제하거나 순서를 변경하는 데 필요한 Editmode 를 지원하지 않는다. 따라서 그냥 PaletteManager 자체를 통채로 iOS 폴더 밑으로 올겨준다.

  • 그러고 나서 macOS 에서는 PaletteManagerEmptyView 가 되도록 typealias 하는데, EmptyView 는 컴파일을 위한 꼼수일 뿐 실제로 UI 에 나타나기를 원하는 것은 아니므로 #if ~ #endif 문으로 iOS 일 때만 PaletteManger 버튼이 나타나도록 한다.

// macOS
typealias PaletteMangaer = EmptyView 
struct PaletteChooser: View {
    @ViewBuilder
    var contextMenu: some View {
        ...
        #if os(iOS)
        AnimatedActionButton(title: "Manager", systemImage: "slider.vertical.3") {
            managing = true
        }
        #endif
        gotoMenu
    }
}

# horizontalSizeClass

  • 얘도 macOS 지원하지 않는다. horizontalSizeClassiOS 디바이스에서 너비가 좁을 때는 redo/undo, paste, camera, photo library 버튼들을 contextMenu 안에 넣어버리기 위해 쓴다. macOS 에서는 너비가 좁을 일이 없고, 크기를 항상 키울 수 있으므로 현재 너비가 충분한지 확인할 필요가 없다! 따라서 #if ~ #else ~ #endif 를 사용해서 macOS 인 경우 compact = false 가 되도록 선언한다.
struct CompactableIntoContextMenu: ViewModifier {
    #if os(iOS)
    @Environment(\.horizontalSizeClass) var horizontalSizeClass
    var compact: Bool { horizontalSizeClass == .compact }
    #else
    let compact = false
    #endif
    
    func body(content: Content) -> some View {
        if compact {
            Button {
                
            } label: {
                Image(systemName: "ellipsis.circle")
            }
            .contextMenu {
                content
            }
        } else {
            content
        }
    }
}

# UIDevice

  • macOS 에는 UIDivice 클래스가 없다. UIDevicewrappedInNavigationViewToMakeDismissable(_:) 메서드와 dismissable(_:) 메서드에서 사용하는 데, 이 두 메서드는 아이폰의 경우 화면이 작아 PaletteEditor 외부 영역을 터치하는 방식으로 창을 닫을 수 없기 때문에 PaletteEditor 상단에 close 버튼 을 달아주기 위한 친구들이다. macOS 는 화면 크기가 넓고, 항상 창 크기를 키울 수 있으므로 그냥 iOS 폴더 밑에 iOS 용 파일을 하나 선언해서 거기로 옮겨준다.

  • 그리고 컴파일이 되도록 macOS 용 파일에 아무런 동작 없이 그냥 현재 View 를 리턴하는 wrappedInNavigationViewToMakeDismissable(_:) 메서드를 View extension 에 추가해준다.

// macOS

extension View {
    func wrappedInNavigationViewToMakeDismissable(_ dismiss: (() -> Void)?) -> some View {
        self
    }
}

# Camera, PhotoLibrary

  • 아까 앞에서 CameraPhotoLibrary 구조체를 iOS 폴더 로 옮겨줬기 때문에(iOS 에서만 해당 기능을 지원하게 할 거라서) 컴파일이 안된다. macOS 용 파일에서 EmptyView 를 선언한 다음 이걸 CameraPhotoLibrarytypealias 해준다. 여기서 주의할 점은 isAvailable 프로퍼티 를 이용해서 EmptyView 가 실제로 UI 에 뜨는 일이 없도록 설정할 수 있어서 이러한 방법을 사용할 수 있다는 것!
    • CantDoItPhotoPickerView 여야 하는 이유는, CameraPhotoLibraryView 이기 때문
// macOS

struct CantDoItPhotoPicker: View {
    var handlePickedImage: (UIImage?) -> Void
    
    static let isAvailable = false
    
    var body: some View {
        EmptyView()
    }
}

typealias Camera = CantDoItPhotoPicker
typealias PhotoLibrary = CantDoItPhotoPicker

jpegData 대신 imageData

  • handlePickedBackgroundImage(_:) 메서드는 UIImage? 를 받아서 Data 타입으로 변환하는데 macOS 에서 사용하는 (즉, UIImage 가 실제로 나타내는)NSImage 에는 jpegData(compressionQuality:) 메서드가 없다. 따라서 일종의 추상화 방식을 채택해서 UIImage extension 으로 imageData 프로퍼티를 선언해서 플랫폼별로 적절하게 구현한다.
struct EmojiArtDocumentView: View {
    private func handlePickedBackgroundImage(_ image: UIImage?) {
        autozoom = true
        if let imageData = image?.imageData {
            document.setBackground(.imageData(imageData), undoManager: undoManager)
        }
        backgroundPicker = nil
    }
}
  • iOS 에서는 기존과 같이 jpegData(compressionQuality:) 메서드를 사용하고, macOS 에서는 tiffRepresentation 프로퍼티를 이용해서 TIFF Data 를 얻는다.
// iOS
extension UIImage {
    var imageData: Data? { jpegData(compressionQuality: 1.0) }
}

// macOS
extension UIImage {
    var imageData: Data? { tiffRepresentation }
}

# Pasteboard

  • macOS 에는 NSPasteboard 가 있는데 UIPasteboard 와 다르므로 Pasteboard 구조체 를 선언해서 위에서처럼 추상화 해준다. Pasteboard 에서 imageData 를 받아오거나 url 을 받아오므로 이를 구현한다.
struct EmojiArtDocumentView: View {
    private func pasteBackground() {
        autozoom = true
        if let imageData = Pasteboard.imageData {
            document.setBackground(.imageData(imageData), undoManager: undoManager)
        } else if let url = Pasteboard.imageURL {
            document.setBackground(.url(url), undoManager: undoManager)
        } else {
            alertToShow = IdentifiableAlert(
                title: "Paste Background",
                message: "There is no image currently on the pasteboard."
            )
        }
    }
}
// iOS
struct Pasteboard {
    static var imageData: Data? {
        UIPasteboard.general.image?.imageData
    }
    static var imageURL: URL? {
        UIPasteboard.general.url?.imageURL
    }
}

// macOS
struct Pasteboard {
    static var imageData: Data? {
        NSPasteboard.general.data(forType: .tiff) ?? NSPasteboard.general.data(forType: .png)
    }
    static var imageURL: URL? {
        // not as? b/c guranteed conversion b/w NSURL and URL
        (NSURL(from: NSPasteboard.general) as URL?)?.imageURL
    }
}

# loadobjects!

  • drop(providers:at:) 메서드는 드랍받은 이미지를 배경화면으로 설정하는데, 이 과정에서 NSItemProviderloadObjects(of:) 메서드를 사용한다. 문제는, NSImage 의 경우 itemProvider 가 존재하지 않는다. 따라서, #if ~ #endifiOS 에서만 UIImage 를 드래그 & 드랍할 수 있게 한다. 이렇게 하더라도 macOS 에서는 url 형태로 이미지를 받으면 되므로, 드래그 앤 드랍을 지원한다.
struct EmojiArtDocumentView: View {
    private func drop(providers: [NSItemProvider], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
        var found = providers.loadObjects(ofType: URL.self) { url in
            autozoom = true
            document.setBackground(.url(url.imageURL), undoManager: undoManager)
        }
        #if os(iOS)
        if !found {
            found = providers.loadObjects(ofType: UIImage.self) { image in
                if let data = image.jpegData(compressionQuality: 1.0) {
                    autozoom = true
                    document.setBackground(.imageData(data), undoManager: undoManager)
                }
            }
        }
        #endif
        ...
    }
}

Multiplatform 앱으로 바꿔주기 2 : 컴파일은 됐는데 뭔가 이상한데요?

# .plaintext 대신 .utf8plaintext

  • 컴파일한 직후에 팔레트를 이모지에서 선택해서 배경에 드랍하면 화면에 추가되는 게 아니라 계속 사라진다. macOS 에서는 드래그 앤 드랍을 통해 받을 수 있는 타입 중 하나로 String 을 선언할 때, .plainText 가 아니라 .utf8PlainTextUTI 로 사용하기 때문이다. 이렇게 선언했을 때 iOS 에서도 작동하므로 그냥 바꿔준다.
    • cf. JSONutf8PlainText 형식
struct EmojiArtDocumentView: View {
    var documentBody: some View {
        GeometryReader { geometry in
            ZStack {
                // some code 
            }
            .onDrop(of: [.utf8PlainText,.url,.image], isTargeted: nil) { providers, location in
                drop(providers: providers, at: location, in: geometry)
            }
        }
    }
}

# 못생긴 팔레트...

버튼 스타일 바꾸기

  • 일단 팔레트 버튼이 찌부됐다...macOS 에서 표준 여백을 팔레트 버튼 아이콘에 적용하기 때문이다...따라서 macOS 인 경우에는 버튼 스타일을 PlainButtonStyle() 이 되도록 하는 View extension 을 선언할 건데 컴파일되도록 iOS 에서도 동일한 이름의 아무것도 하지 않는 extension 을 선언해줘야 한다.

  • 다른 문제 중 하나는 macOS 의 경우 이모지 개수가 많아서 스크롤 해야 하는 경우 제스처로 안되기 때문에 스크롤바를 띄우는데, 이모지 개수가 적은 팔레트는 스크롤바가 없어서 팔레트를 넘길 때 계속 팔레트 창의 크기가 바뀐다. 이 문제는 버튼 상하에 여백을 줘서 해결할 수 있으므로 위에서 extension 에 추가한 메서드를 통해 한번에 해결해줄것...!

// macOS
extension View {
    func paletteControlButtonStyle() -> some View {
        self.buttonStyle(PlainButtonStyle()).foregroundColor(.accentColor).padding(.vertical)
    }
}

// iOS
```swift
extension View {
    func paletteControlButtonStyle() -> some View {
        self
    }
}
  • 바뀐 모습


편집창 좌우여백 넣어주기

  • 그리고 편집창을 띄우면 좌우 여백이 이상하다. 아까와 같은 방식으로 macOSiOS 모두에서 View extension 에 이름이 같은 메서드를 선언하고, 전자에서는 좌우 여백을 넣어주고, 후자에서는 아무런 동작도 하지 않게 한다.
// macOS
extension View {
    func popoverPadding() -> some View {
        self.padding(.horizontal)
    }
}

// iOS
extension View {
    func popoverPadding() -> some View {
        self
    }
}
  • 아무튼 예뻐짐


# macOS 에서 redo/undo 버튼 없애기

  • macOS 는 상단에 시스템 Edit 메뉴를 제공하므로 redo/undo 버튼이 불필요하다! 따라서 iOS 에서만 나타나도록 바꿔준다.
struct EmojiArtDocumentView: View {
    var documentBody: some View {
        GeometryReader { geometry in
            ZStack {
                // some code...
            }
            .compactableToolbar {
                #if os(iOS)
                if let undoManager = undoManager {
                    if undoManager.canUndo {
                        AnimatedActionButton(title: undoManager.undoActionName, systemImage: "arrow.uturn.backward") {
                            undoManager.undo()
                        }
                    }
                    if undoManager.canRedo {
                        AnimatedActionButton(title: undoManager.redoActionName, systemImage: "arrow.uturn.forward") {
                            undoManager.redo()
                        }
                    }
                }
                #endif
            }
        }
    }
}

☀️ 느낀점

  • 멀티플랫폼 앱이 되도록 하기 위해서는 typealias, #if #else #endif, platform-specific file 을 사용할 수 있다는 점을 배웠다. 특히, 각 플랫폼별 파일을 만들어서 한쪽에서는 특정 메서드가 기능하게 하고 다른 쪽에서는 아무런 기능도 하지 않게 하는 방식으로 많은 문제를 해결할 수 있었다! 주의할 점은 컴파일을 위해 한쪽에서는 그냥 EmptyView 를 리턴하게 하는 경우 최대한 EmptyView 자체가 UI 상에 뜨지 않도록 조건을 설정해줘야 하고, 그게 불가능한 경우 다른 해결책을 모색해야 하는게 좀 더 좋은 코드라는 것을 배웠다.
  • Info.plist 를 그냥 파일에서 설정했을 때 이상하게 안 먹혀서 결국 EmojiArt 를 누르면 나오는 셋팅 창에서 설정하느라 시간 엄청 날렸다...아직도 원인을 모르겠음...
  • 완!!!!!!!!강!!!!!!!!!!!! 부끄럽지만 개발 공부를 시작하고 나서 밟은 코스 중에 첫 완강이다...ㅎㅎㅎ 아주 짧게 The Odin Projectjs 트랙을 밟았었고 cs193p 를 듣기 직전에 100 days of SwiftUI 를 한 10일차인가까지 했었다...중간에 이것저것 다른 공부를 병행하느라 처음 계획한 것보다 많이 밀리긴 했지만 아무튼 과제도 다했고, 강의도 다 듣고, 블로그도 정리했으니까 내 자신 장하다...!

  • 과제...? TODO: figure out a way to drop an NSImage on macOS?







  • differenet Info.plist for each b/c much of project settings are platform specific

  • entitlements: security setting that allow ur application to do/not do certain things..similiar to privacy settings in the info.plist

  • 6:30 ) plist 설정 8분

-mac: Csbunldetupe role : Editor

  • set target for macOS, camera, photolibrary

  • make wrappedNavigation... return self in MacOS
    -? ViewModifiers are a good way to have platform specific behavior

  • collect things in a platform specific file or have them #if #endif

loadObjects

  • NSImages are not provided with item providers.. =only url :)

  • making the drop work

  • getting rid of the undo button

  • fixing the PaletteChooser :
    - scrollbar adjustment -> need to make size fits the scrollbar all the time

    • mac putting its standard borders arround the palette button(icon)
  • pad popover

  • mac os entitlement 설정 : com.apple.sercurity.network.client- boolean-true

profile
☀️

0개의 댓글

관련 채용 정보