[iOS] UICollectionViewCell에서 SwiftUI를 임베딩할 때 발생하는 Safe Area 이슈 해결하기

Zerom·2026년 1월 12일

iOS 정리

목록 보기
7/14

개요

UIKit 기반의 UICollectionView에서 SwiftUI 뷰를 사용하기 위해 UIHostingController를 활용하는 경우가 있습니다. 이 글에서는 이 패턴을 사용할 때 발생할 수 있는 이미지가 잘리거나 콘텐츠가 밀리는 현상의 원인과 해결 방법을 다룹니다.


문제 상황

증상

  • UICollectionViewCell 내부에 SwiftUI 뷰를 UIHostingController로 임베딩
  • 이미지 상단이 잘려 보이거나 콘텐츠가 밀린 것처럼 보이는 현상
  • Cell의 frame은 정상이지만, 내부 콘텐츠 크기가 줄어들어 있음

발생 조건

Samuel Défago의 블로그에서 정확한 발생 조건을 설명합니다:

"cells initially on screen have correct frames, while cells emerging from screen edges do not"

즉, 스크롤하면서 화면 가장자리에서 새로 나타나는 Cell에서 문제가 발생합니다. 처음부터 화면에 보이는 Cell은 정상입니다.


원인 분석

UIHostingController의 Safe Area 자동 적용

Samuel Défago의 블로그에서 이 문제를 정확히 분석하고 있습니다:

"When inspected in the view debugger, UICollectionViewCell and UIHostingController view frames are fine, so the problem must be related to how SwiftUI assigns a frame to views contained in a UIHostingController. In fact, closer inspection of the applied frames reveals that the reduction in size is due to safe area insets being somehow applied."

핵심 원인:

  • View Debugger에서 Cell과 UIHostingController의 frame은 정상
  • 문제는 UIHostingController가 내부 SwiftUI 뷰에 Safe Area Insets를 자동 적용하는 것
  • Cell이 화면 가장자리(Safe Area 경계)에서 나타날 때 이 동작이 트리거됨

왜 .ignoresSafeArea()가 작동하지 않는가?

SwiftUI 내부에서 .ignoresSafeArea() modifier를 사용해도 UIHostingController 레벨에서 적용되는 Safe Area는 무시할 수 없습니다.

// 이 방법은 작동하지 않음
struct ContentView: View {
    var body: some View {
        Image("photo")
            .resizable()
            .ignoresSafeArea()  // UIHostingController의 Safe Area에는 영향 없음
    }
}

Apple Radar (FB8176223)에도 이 문제가 보고되어 있습니다:

"When embedding a SwiftUI view into UIKit, there is no way to disable SafeArea behaviour... There is a _disableSafeArea bool on UIHostingView, however this is private."


문제가 발생하는 코드 패턴

UICollectionViewCell + UIHostingController 임베딩

class HostingCollectionViewCell: UICollectionViewCell {
    private var hostingController: UIHostingController<AnyView>?

    func configure(with view: AnyView) {
        let controller = UIHostingController(rootView: view)
        hostingController = controller

        contentView.addSubview(controller.view)
        controller.view.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            controller.view.topAnchor.constraint(equalTo: contentView.topAnchor),
            controller.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            controller.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            controller.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }
}

위 코드에서 UIHostingController는 Cell이 화면 가장자리에서 나타날 때 Safe Area Insets를 SwiftUI 콘텐츠에 자동 적용합니다.

왜 순수 UIKit Cell에서는 문제가 없는가?

// 순수 UIKit Cell - Safe Area 문제 없음
class PureUIKitCell: UICollectionViewCell {
    private let imageView = UIImageView()

    func configure(with image: UIImage) {
        imageView.image = image
        // UIImageView는 UIHostingController의 Safe Area 동작과 무관
    }
}

순수 UIKit으로 구현된 Cell은 UIHostingController를 사용하지 않으므로 이 문제가 발생하지 않습니다.


해결 방법

방법 1: iOS 16.4+ 공식 API 사용

iOS 16.4부터 Apple은 safeAreaRegions 프로퍼티를 제공합니다.

let controller = UIHostingController(rootView: view)

if #available(iOS 16.4, *) {
    controller.safeAreaRegions = []  // Safe Area 비활성화
}

Apple Developer Documentation에 따르면, safeAreaRegions를 빈 Set으로 설정하면 모든 Safe Area 처리가 비활성화됩니다.

방법 2: iOS 16.4 미만을 위한 런타임 해결책

iOS 16.4 미만 버전을 지원해야 한다면, 동적 서브클래싱(Dynamic Subclassing) 기법을 사용합니다.

Samuel Défago의 블로그에서 제안하는 방법입니다:

"A more surgical approach than method swizzling is to use dynamic subclassing, the runtime wizardry applied by key-value observing."

extension UIHostingController {
    func disableSafeArea() {
        guard let viewClass = object_getClass(view) else { return }

        let viewSubclassName = String(cString: class_getName(viewClass))
            .appending("_IgnoreSafeArea")

        if let viewSubclass = NSClassFromString(viewSubclassName) {
            object_setClass(view, viewSubclass)
        } else {
            guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String,
                  let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0)
            else { return }

            if let method = class_getInstanceMethod(
                UIView.self,
                #selector(getter: UIView.safeAreaInsets)
            ) {
                let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                    return .zero
                }
                class_addMethod(
                    viewSubclass,
                    #selector(getter: UIView.safeAreaInsets),
                    imp_implementationWithBlock(safeAreaInsets),
                    method_getTypeEncoding(method)
                )
            }

            objc_registerClassPair(viewSubclass)
            object_setClass(view, viewSubclass)
        }
    }
}

동작 원리

  1. 동적 서브클래스 생성: 런타임에 UIHostingController의 view 클래스를 서브클래싱
  2. safeAreaInsets Override: safeAreaInsets getter를 override하여 .zero 반환
  3. 선택적 적용: 특정 인스턴스에만 적용되므로 다른 UIHostingController에는 영향 없음

Method Swizzling 대신 Dynamic Subclassing을 사용하는 이유

"This workaround applies swizzling to all hosting view instances indiscriminately, disabling safe area inset support entirely for all hosted SwiftUI views. This approach is too greedy."

Method Swizzling은 앱 전체의 모든 UIHostingController에 영향을 주지만, Dynamic Subclassing은 필요한 인스턴스에만 선택적으로 적용할 수 있습니다.

방법 3: 두 가지 방법 통합

iOS 버전에 따라 적절한 방법을 선택하는 통합 코드:

extension UIHostingController {
    func disableSafeArea() {
        if #available(iOS 16.4, *) {
            safeAreaRegions = []
        } else {
            disableSafeAreaLegacy()
        }
    }

    private func disableSafeAreaLegacy() {
        guard let viewClass = object_getClass(view) else { return }

        let viewSubclassName = String(cString: class_getName(viewClass))
            .appending("_IgnoreSafeArea")

        if let viewSubclass = NSClassFromString(viewSubclassName) {
            object_setClass(view, viewSubclass)
        } else {
            guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String,
                  let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0)
            else { return }

            if let method = class_getInstanceMethod(
                UIView.self,
                #selector(getter: UIView.safeAreaInsets)
            ) {
                let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                    return .zero
                }
                class_addMethod(
                    viewSubclass,
                    #selector(getter: UIView.safeAreaInsets),
                    imp_implementationWithBlock(safeAreaInsets),
                    method_getTypeEncoding(method)
                )
            }

            objc_registerClassPair(viewSubclass)
            object_setClass(view, viewSubclass)
        }
    }
}

적용 예시

class HostingCollectionViewCell: UICollectionViewCell {
    private var hostingController: UIHostingController<AnyView>?

    func configure(with view: AnyView) {
        let controller = UIHostingController(rootView: view)
        controller.disableSafeArea()  // Safe Area 비활성화
        hostingController = controller

        contentView.addSubview(controller.view)
        controller.view.translatesAutoresizingMaskIntoConstraints = false
        controller.view.backgroundColor = .clear

        NSLayoutConstraint.activate([
            controller.view.topAnchor.constraint(equalTo: contentView.topAnchor),
            controller.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            controller.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            controller.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        hostingController?.view.removeFromSuperview()
        hostingController = nil
    }
}

iOS 16+ 대안: UIHostingConfiguration

iOS 16부터는 UIHostingConfiguration을 사용하여 더 간단하게 SwiftUI를 Cell에 임베딩할 수 있습니다.

cell.contentConfiguration = UIHostingConfiguration {
    MySwiftUIView()
}
.margins(.all, 0)  // 마진 제거

WWDC22 - Use SwiftUI with UIKit에서 소개된 이 방식은 Apple이 공식적으로 제공하는 API로, Safe Area 관련 문제를 더 안정적으로 처리합니다.

UIHostingConfiguration 사용 시 주의사항

UIHostingConfiguration은 편리하지만, Apple Developer ForumsSwift by Sundell에서 언급된 몇 가지 제한사항이 있습니다:

1. Environment 격리

"Every UIHostingController creates its own SwiftUI environment, which means you can't share data via environment."

각 Cell이 독립적인 SwiftUI Environment를 가지므로, 상위 뷰와 Environment를 통한 데이터 공유가 불가능합니다.

// 이 방식은 UIHostingConfiguration에서 작동하지 않음
cell.contentConfiguration = UIHostingConfiguration {
    MyView()
        .environmentObject(sharedViewModel)  // Cell 내부에서만 유효
}

2. 데이터 흐름 패턴의 변화

Environment 격리로 인해, UIHostingConfiguration에서는 상위 뷰와의 데이터 공유 방식이 달라집니다. @Binding을 직접 사용하기보다는 @ObservedObject나 클로저 패턴이 권장됩니다.

// UIHostingController 방식 - @Binding으로 직접 연결 가능
struct MediaCellView: View {
    @Binding var isPlaying: Bool
    @Binding var currentIndex: Int
}

// UIHostingConfiguration 방식 - 클로저나 ObservableObject 패턴 권장
cell.contentConfiguration = UIHostingConfiguration {
    MyCellView(
        data: item,
        onTap: { /* 클로저로 이벤트 전달 */ }
    )
}

Swift by Sundell에서도 클로저를 통한 이벤트 전달 패턴을 권장합니다.

3. UIViewControllerRepresentable 미지원

"SwiftUI views that depend on UIViewControllerRepresentable can't be used inside of cells."

Cell 내부에서 UIViewControllerRepresentable을 사용하는 뷰는 UIHostingConfiguration과 호환되지 않습니다. 단, UIViewRepresentable은 사용 가능합니다.

"NavigationLink within UIHostingConfiguration wouldn't automatically be wired up to any UINavigationController."

Cell 내부의 NavigationLink는 자동으로 UINavigationController와 연결되지 않습니다. Coordinator 패턴을 통해 별도로 처리해야 합니다.

UIHostingConfiguration vs UIHostingController 선택 기준

상황권장 방식
단순한 표시 전용 CellUIHostingConfiguration
복잡한 양방향 데이터 바인딩 필요UIHostingController + disableSafeArea()
NavigationLink 사용UIHostingController
UIViewControllerRepresentable 포함UIHostingController
Environment 공유 필요UIHostingController

정리

문제원인해결책
Cell 콘텐츠가 잘리거나 크기가 줄어듦UIHostingController의 Safe Area 자동 적용disableSafeArea() 호출
스크롤 시 새로 나타나는 Cell에서만 발생Cell이 화면 가장자리(Safe Area 경계)에서 나타날 때 트리거됨모든 Cell에 Safe Area 비활성화 적용
.ignoresSafeArea() 작동 안함UIHostingController 레벨 문제UIHostingController에서 직접 처리 필요

핵심 포인트

  1. UIHostingController는 Safe Area Insets를 자동 적용합니다
  2. 이 문제는 Cell이 화면 가장자리에서 나타날 때 발생합니다
  3. SwiftUI의 .ignoresSafeArea()로는 이 동작을 비활성화할 수 없습니다
  4. iOS 16.4+에서는 safeAreaRegions = [] 사용
  5. iOS 16.4 미만에서는 동적 서브클래싱으로 safeAreaInsets를 override
  6. iOS 16+에서는 UIHostingConfiguration 사용을 고려

참고 자료

profile
꼼꼼한 iOS 개발자 /
Apple Developer Academy @ POSTECH 2기 / 멋쟁이사자처럼 앱스쿨 1기

0개의 댓글