[iOS] - Preview UIViewController in Xcode


Xcode에서 UIViewController를 preview를 통해 미리 확인할 수 있을까?


image

위 이미지는 Xcode를 구글에서 검색한 결과로, 굉장히 멋있다. 왼쪽에서는 코딩을 하고, 오른쪽에서는 내가 코딩을 하는대로 그 결과가 화면에서 실제로 어떻게 보여지는 지를 확인할 수 있는데, 정말 정말 편리한 기능일 것이라고 생각하는데, 정작 나는 저 기능을 지금까지 개발하며 써본 적이 없었다.

image

나는 이런 코드만 계속 작성하다가 한 번 화면에 어떻게 나오는 지를 확인하고 싶으면 Run 해서 시뮬레이터에서 확인하고, 수정 사항이 생기면 다시 코드를 수정하고, 다시 빌드-실행 하고… 이 무한의 굴레를 반복했다. 물론 처음 한 번 빌드하고 나서 수정 후 다시 빌드하고 실행하는 시간이 그렇게 오래 걸리는 것은 아니다. 하지만 굳이 수정사항을 확인하기 위해 빌드하고 실행하고 다시 수정하고 빌드하고 실행하고 이런 사이클을 돌 필요는 없다고 생각한다.

Xcode를 소개하는 이미지를 찾다보면 저런 2분할 화면(코드-시뮬레이터)을 쉽게 볼 수 있는데, 이는 그만큼 이 기능이 굉장히 Xcode의 핵심 기능임을 나타내는 것이 아닐까? 실제로 주 기능이 아니더라도 분명 개발하는 데 도움을 주는 것은 맞는 것으로 보인다. 그래서 이 멋진 기능을 사용하는 방법을 알아볼 것이다.

Preview

위와 같이 내가 만든 뷰를 미리 보여주는 걸 Preview라고 한다. 이 Preview라는 기능을 통해 내가 만든 뷰의 동적이고, 상호작용 가능한 프리뷰들을 만들 수 있다.

Previews in Xcode라는 공식 문서를 보면, 이 기능은 Swift Framework에서 사용할 수 있다. 그렇다고 UIKit의 UIViewController, UIView를 Preview로 띄우지 못하는 것은 아니다. UIViewController와 UIView를 Preview로 띄우는 것은 좀 뒤에 해보겠다.

SwiftUI로 내가 임의로 View를 만들면, Xcode가 내가 이 view의 코드를 수정할 때마다 바뀌는 뷰의 컨텐츠의 preview를 보여준다. 이 Preview를 이용하려면 Xcode에게 띄워달라고 할 구조체를 정의해야 하는데, 이 구조체는 PreviewProvider 프로토콜을 채택해야 한다. 그러면 코드 옆의 canvas에 아래와 같이 preview를 보여준다.

image

간단히 설명하면 위 화면의 오른쪽에 프리뷰가 뜬 곳이 canvas다. 이 캔버스에 대해 자세히 설명된 좋은 글이 있으니 시간이 있으면 읽어보는 것이 좋을 것 같다. 이 캔버스에서 지원하고 있는 기능 중 하나가 Preview인 것이다.

다시 preview로 돌아와서, preview를 구성하기 위해 previewDevice(_:)previewInterfaceOrientation(_:)과 같은 preview-specific modifier를 포함해서 view modifier를 사용할 수 있다.

위에서 view의 코드를 내가 수정하면 이 수정사항이 반영된 view의 content를 프리뷰로 보여준다고 했는데, 더 놀라운 건 canvas에 만든 수정사항도 code로 반영시켜준다는 것이다. 정말 멋진 기능이다.

PreviewProvider protocol

위에서 preview를 이용하려면 Xcode에게 띄워달라고 할 구조체를 정의해야 하는데, PreviewProvider를 따르게 해야 한다고 했다. 그러면 이 PreviewProvider 프로토콜도 무엇인지 보자.

PreviewProvider 프로토콜은 Xcode에서 view의 프리뷰를 생성하는 타입이다. iOS 13부터 사용 가능하다. iPadOS는 13.0, watchOS는 6.0 부터 사용가능하다.

사용하는 방법은, PreviewProvider 프로토콜을 채택하는 구조체를 선언해서 Xcode preview를 만드는 것이다. 요구되는 previews 연산 프로퍼티를 구현하고, 보여줄 view를 리턴한다.

struct CircleImage_Previews: PreviewProvider {
    // View 리턴, CircleImage 인스턴스를 리턴하고 있다.
    static var previews: some View {
        CircleImage()
    }
}

그런데 CircleImage_Previews 처럼 PreviewProvider를 따르는 구조체를 그냥 정의해두면 Xcode가 알 수 있을까? Xcode는 내 프로젝트 안에 있는 preview provider를 찾고, 현재 소스 에디터에 띄워져 있는 provider들의 preview를 생성한다.

프리뷰가 어떻게 나타나는지를 바꾸려면위에서 View를 생성할 때 했던 것처럼 view modifier를 추가하면 된다. 이를 통해 디바이스 가로-세로 방향등을 바꿀 수 있다.

struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        CircleImage()
            .previewInterfaceOrientation(.landscapeLeft)
    }
}

image

이렇게 프리뷰를 커스터마이징 하는 것에 대해 더 보려면 Previews in Xcode를 참고하면 된다.

Xcode는 preview안에 내가 만든 각각의 뷰마다 다른 preview를 생성해주기 때문에, 뷰의 다양한 버전들을 프리뷰로 한 번에 확인할 수 있다. 예를 들어 밝은 view와 어두운 view를 동시에 확인하고 싶다면 아래와 같이 설정하면 된다.

struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        CircleImage()
        CircleImage()
            .preferredColorScheme(.dark)
    }
}

다양한 뷰를 동시에 보고 싶은데, 하나의 modifier를 모두에게 적용하려면 Group을 사용하면 된다.

struct CircleImage_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            CircleImage()
            CircleImage()
                .preferredColorScheme(.dark)
        }
        .previewLayout(.sizeThatFits) // 밝은 CircleImage와 어두운 CircleImage에 sizeThatFit를 적용했다.
    }
}

image

위에서 required computed property인 preivews 구현해서 보여줄 view를 리턴한다고 했다. 이 프로토콜에서 설정할 수 있는(구현할 수 있는) property는 previewsplatform이 있다.

image

프리뷰를 생성하기 위해 어떤 뷰를 리턴할지 정해주는 것은 preview를 쓰면 되고, 어떤 플랫폼에서 provider를 실행시킬지 정하는 것은 platform을 사용하면 된다.

보면 previewsplatform이 requried라고 되어 있는데, platform의 경우 멀티 플랫폼 타겟을 가지고 있지 않는 한 이 값을 무시한다고 한다.

또 associatedtype인 Previews가 있다. 얘는 따로 provider 내에서 지정하지 않고 preview를 생성할 때, previews 프로퍼티에서 구현된 것에서 이 타입을 자동으로 추론해준다.

사용

UIViewController preview

일단 간단하게 CollectionView가 있는 UIViewController를 만들었다. 이 collectionView의 cell들은 고양이들의 이름을 출력해 줄 것이다.

image

코드만 작성하면 위와 같다. Preview를 보기 위해서는 우선 Canvas도 Xcode에 띄워줘야 한다. 우측 상단쪽에 있는 아래의 버튼을 클릭해서 Canvas를 선택해준다.

image

그런데 화면상 달라진 것은 없다. 그 이유는 preview는 Xcode가 현재 띄워진 파일에서 preview provider를 찾는데 내가 지금 작성한 코드는 PreviewProvider를 채택한 구조체가 없기 때문이다.

이제 preview provider를 만들어줘야 한다.

import SwiftUI

@available(iOS 13.0, *)
struct CatViewControllerPreview: PreviewProvider {
    static var previews: some View {
        // 이 안에서 preview로 보고자 하는 뷰를 리턴
    }
}

위의 코드를 추가해줬는데, 아직 preview에서 어떤 뷰를 리턴할 지를 정해주지 않았기 때문에 에러가 난다. 아래의 코드를 추가로 작성한다.

@available(iOS 13.0, *)
extension UIViewController {

    private struct Preview: UIViewControllerRepresentable {
        let viewController: UIViewController

        func makeUIViewController(context: Context) -> some UIViewController {
            viewController
        }

        func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
    }

    public var preview: some View {
        return Preview(viewController: self)
    }
}

UIViewController에 preview라는 프로퍼티를 만들어줬다. 그리고 아까 preview provider의 코드에 뷰를 리턴해주는 부분을 추가한다.

@available(iOS 13.0, *)
struct CatViewControllerPreview: PreviewProvider {
    static var previews: some View {
        let catViewController = ViewController()
        return catViewController.preview
    }
}

image

여기까지 하면 오른쪽에 canvas가 뜨는 것을 확인할 수 있다! Canvas 상단의 resume 버튼을 눌러준다.

image

그런데 나는 분명히 collection view를 만들어서 고양이들의 이름을 출력해주는 화면을 만들었는데, preview에서 보이지 않는다. 이는 preview의 재생 버튼같이 생긴걸 눌러주면 된다. 참고로 UICollectionView같은 것이 있으면 프리뷰에서 이 UICollectionView를 스크롤 할 수도 있다.

image

그러면 아래와 같이 preview에서 내가 만든 collectionView가 어떻게 나타나는지를 확인할 수 있다!

image

코드를 수정할 때마다 수정사항이 preview에 반영되고, preview가 만약 멈췄다면 Resume 버튼을 눌러 다시 재개시킬 수 있다. 참고로 이 재생시키는 단축키는 <command/kbd> + <option/kbd> + P키다. 이 preview는 코드를 작성하면서 화면에 의도하지 않은 출력을 보고 바로바로 수정하기 정말 용이한 것 같다. 가령 내가 위에서 만든 collectionView의 cell을 아래와 같이 만들어서 이름 라벨을 contentView에 추가한 것이 아니라 그냥 view(UICollectionViewCell)에 추가했다고 해보자. 그리고 라벨의 레이아웃을 설정할 때는 contentView를 기준으로 잡았다고 구현했다.

class CatCollectionViewCell: UICollectionViewCell {
    
    lazy var nameLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.sizeToFit()
        
        addSubview(label)
        
        return label
    }()
    
    ...
    
    private func setLayoutConstraints() {
        nameLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor).isActive = true
        nameLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true
    }
}

image

그러면 위와 같이 preview를 통해 즉각 내가 무언가를 잘못했음을 알 수 있다. 그러면 cell 쪽 코드를 다시 점검하면서 디버깅을 할 수 있는 것이다.

그리고 라벨의 레이아웃을 설정하는 코드를 아래와 같이 수정하면 preview가 즉각적으로 아래와 같이 변한다.

private func setLayoutConstraints() {
    nameLabel.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
    nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
}

image

앞에서 preview를 커스터마이징 할 수 있다고 했는데, 한 번 preview를 가로화면으로 나타나게 바꿔보았다.

@available(iOS 13.0, *)
struct CatViewControllerPreview: PreviewProvider {
    
    static var previews: some View {
        let catViewController = ViewController()
        if #available(iOS 15.0, *) {
            return catViewController.preview.previewInterfaceOrientation(.landscapeLeft)
        }
        
        return catViewController.preview
    }
}

다만 이렇게 하면 Function declares an opaque return type, but the return statements in its body do not have matching underlying types라는 에러 메세지가 뜨지만, 오른쪽에 preview는 의도한 대로 뜨는 것을 확인할 수 있었다.

image

그래도 에러가 뜨는 걸 고치기 위해 이 글(https://developer.apple.com/forums/thread/118172)을 참고해서 두 가지 버전으로 수정하니 두 버전 모두 에러가 발생하지 않았다. 아직 SwiftUI는 문법을 아예 모르는데 SwfitUI도 공부해봐야겠다.

// 버전 1
static var previews: some View {
    let catViewController = ViewController()
    if #available(iOS 15.0, *) {
        return AnyView(catViewController.preview.previewInterfaceOrientation(.landscapeLeft))
    }

    return AnyView(catViewController.preview)
}

// 버전 2 - 더 깔끔해 보인다. return 문이 없음에 주의하자.
@ViewBuilder
static var previews: some View {
    let catViewController = ViewController()
    if #available(iOS 15.0, *) { catViewController.preview.previewInterfaceOrientation(.landscapeLeft)
    }

    catViewController.preview
}

UIView preview

그럼 UIViewController 말고 UIView의 preview를 보는 것은 이를 응용해서 만들 수 있을 것이다. 참고로 다른 UIView를 preview로 보기 위해 만드는 예제들을 보면 preview에서 뷰를 리턴할 때 명시적으로 사이즈를 지정해주는 부분이 있는데, 나는 autolayout을 사용하고 싶어서 다른 방법을 썼다. 별 의미는 없고, 그냥 편한 걸 골라서 쓰면 될 것 같다.

먼저 아주아주 간단한 TestView를 만들었고, 아래와 같은 코드를 추가했다.

#if canImport(SwiftUI) && DEBUG
import SwiftUI

struct TestViewPreview: UIViewRepresentable {
    let view: UIView

    // 나는 여기에서 view의 width와 height를 수정했다.
    func makeUIView(context: Context) -> UIView {
        view.translatesAutoresizingMaskIntoConstraints = false
        view.widthAnchor.constraint(equalToConstant: 150).isActive = true
        view.heightAnchor.constraint(equalToConstant: 100).isActive = true
        return view
    }

    func updateUIView(_ view: UIView, context: Context) {}
}

#endif

그리고 이제 PreviewProvider를 만들어준다. 아래의 코드도 #if#endif 구문 사이에 넣어주면 된다.

struct CatButtonPreviewProvider: PreviewProvider {
    static var previews: some View {
        TestViewPreview(view: TestView(name: "고영")).previewLayout(.sizeThatFits)
            .padding(10)
    }
}

image

previewLayout(.sizeThatFits)를 설정하지 않으면 시뮬레이터에 뷰가 뜬다.

여기에 추가로 디바이스 별로 뷰를 띄우는 것도 해봤다. deviceNames에 띄우고 싶은 디바이스들의 이름을 넣고, ForEACH 문을 돌며 .previewDeivce()를 설정해주면 된다.

let deviceNames: [String] = [
    "iPhone SE (2nd generation)",
    "iPad 11 Pro Max",
    "iPad Pro (11-inch)"
]

struct CatButtonPreviewProvider: PreviewProvider {
    static var previews: some View {
        
        ForEach(deviceNames, id: \.self) { deviceName in
        TestViewPreview(view: TestView(name: "이름바꾸기")).previewDevice(PreviewDevice(rawValue: deviceName))
            .previewDisplayName(deviceName)

        }
    }
}

image

그런데 iPhone SE로 띄워지는 건 잘 띄워졌는데, iPad로 띄우겠다고 한 거는 띄워지지 않았다. 이는 이름이 정확하지 않기 때문이다.

image

시뮬레이터들의 목록을 보면 디바이스 별로 정확한 이름을 확인할 수 있다. 정확하지 않은 이름이 들어간 경우 현재 선택된 시뮬레이터로 프리뷰가 나오는 것 같다. 이를 참고해서 iPad의 이름을 수정하고(시뮬레이터 목록에서 본 이름과 완전히 똑같이 입력해야 한다. generation까지) 다시 프리뷰를 돌리면 아래와 같이 나온다.

image