UIKit 기반의 UICollectionView에서 SwiftUI 뷰를 사용하기 위해 UIHostingController를 활용하는 경우가 있습니다. 이 글에서는 이 패턴을 사용할 때 발생할 수 있는 이미지가 잘리거나 콘텐츠가 밀리는 현상의 원인과 해결 방법을 다룹니다.
UIHostingController로 임베딩Samuel Défago의 블로그에서 정확한 발생 조건을 설명합니다:
"cells initially on screen have correct frames, while cells emerging from screen edges do not"
즉, 스크롤하면서 화면 가장자리에서 새로 나타나는 Cell에서 문제가 발생합니다. 처음부터 화면에 보이는 Cell은 정상입니다.
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."
핵심 원인:
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
_disableSafeAreabool on UIHostingView, however this is private."
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 - Safe Area 문제 없음
class PureUIKitCell: UICollectionViewCell {
private let imageView = UIImageView()
func configure(with image: UIImage) {
imageView.image = image
// UIImageView는 UIHostingController의 Safe Area 동작과 무관
}
}
순수 UIKit으로 구현된 Cell은 UIHostingController를 사용하지 않으므로 이 문제가 발생하지 않습니다.
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 처리가 비활성화됩니다.
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)
}
}
}
safeAreaInsets getter를 override하여 .zero 반환"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은 필요한 인스턴스에만 선택적으로 적용할 수 있습니다.
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을 사용하여 더 간단하게 SwiftUI를 Cell에 임베딩할 수 있습니다.
cell.contentConfiguration = UIHostingConfiguration {
MySwiftUIView()
}
.margins(.all, 0) // 마진 제거
WWDC22 - Use SwiftUI with UIKit에서 소개된 이 방식은 Apple이 공식적으로 제공하는 API로, Safe Area 관련 문제를 더 안정적으로 처리합니다.
UIHostingConfiguration은 편리하지만, Apple Developer Forums와 Swift by Sundell에서 언급된 몇 가지 제한사항이 있습니다:
"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 내부에서만 유효
}
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에서도 클로저를 통한 이벤트 전달 패턴을 권장합니다.
"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 패턴을 통해 별도로 처리해야 합니다.
| 상황 | 권장 방식 |
|---|---|
| 단순한 표시 전용 Cell | UIHostingConfiguration |
| 복잡한 양방향 데이터 바인딩 필요 | UIHostingController + disableSafeArea() |
| NavigationLink 사용 | UIHostingController |
| UIViewControllerRepresentable 포함 | UIHostingController |
| Environment 공유 필요 | UIHostingController |
| 문제 | 원인 | 해결책 |
|---|---|---|
| Cell 콘텐츠가 잘리거나 크기가 줄어듦 | UIHostingController의 Safe Area 자동 적용 | disableSafeArea() 호출 |
| 스크롤 시 새로 나타나는 Cell에서만 발생 | Cell이 화면 가장자리(Safe Area 경계)에서 나타날 때 트리거됨 | 모든 Cell에 Safe Area 비활성화 적용 |
.ignoresSafeArea() 작동 안함 | UIHostingController 레벨 문제 | UIHostingController에서 직접 처리 필요 |
.ignoresSafeArea()로는 이 동작을 비활성화할 수 없습니다safeAreaRegions = [] 사용safeAreaInsets를 overrideUIHostingConfiguration 사용을 고려