UIKit 타입을 SwiftUI의 .composable(update:)로 표시해요.
설치 첫 composable 크기 계산 Coordinator 연결 예제 앱 MIT License
UIComposable은 UIView와 UIViewController 타입을 SwiftUI 화면에 연결해요. UIKit 객체는 SwiftUI identity가 시작될 때 생성되고 같은 identity 동안 재사용돼요. 기본 Bridge는 외부 update만 적용하고, Delegate처럼 별도 객체가 필요한 경우에는 UICoordinatedComposable이 Coordinator lifecycle을 관리해요.
iOS 17 이상과 Swift 6 이상이 필요해요.
UIComposable은 Swift Package Manager에서 설치해요. 0.x.y에서는 minor version이 호환성을 보장하지 않으므로 Up to Next Minor Version으로 0.3.x 범위의 업데이트를 받아요.
Xcode에서는 File > Add Package Dependencies...를 선택하고 아래 URL을 입력해요. Dependency Rule은 Up to Next Minor Version, 버전은 0.3.0으로 설정해요.
https://github.com/opficdev/UIComposable.git
Package.swift에서는 다음 dependency와 product를 target에 추가해요.
dependencies: [
.package(
url: "https://github.com/opficdev/UIComposable.git",
.upToNextMinor(from: "0.3.0")
)
].target(
name: "AppFeature",
dependencies: [
.product(name: "UIComposable", package: "UIComposable")
]
)재현 가능한 build가 필요하면 Dependency Rule을 Exact Version으로 설정하거나 Package.swift의 dependency를 .exact("0.3.0")으로 바꿔요.
UIComposable을 채택한 UIView 또는 UIViewController 타입에서 정적 .composable(update:)를 호출해요. SwiftUI가 객체를 처음 표시할 때 Bridge가 UIKit 객체를 만들고, 이후에는 실제로 표시 중인 객체에 update를 적용해요. 아래 코드는 ExampleApp의 기본 CollectionView 예제에서 핵심 호출을 발췌했어요. DemoCollectionView는 예제 앱에서 정의한 타입이며 구현 코드를 함께 볼 수 있어요.
import SwiftUI
import UIComposable
struct BasicCollectionViewDemo: View {
@State private var itemCount = 6
var body: some View {
DemoCollectionView
.composable { collectionView in
collectionView.applyItems(Array(1...itemCount))
}
.frame(height: 280)
}
}UIComposable 채택 타입은 Bridge가 직접 생성할 수 있도록 무매개변수 init()을 제공해야 해요.
UIComposable과 .composable(update:)는 @MainActor API예요. UIKit 객체 생성과 update는 main actor에서 수행돼요.
sizeThatFits:를 지정하면 SwiftUI가 제안한 크기와 실제로 표시 중인 UIKit 인스턴스를 받아 필요한 크기를 반환할 수 있어요. 지정하지 않으면 Bridge는 nil을 반환하고 SwiftUI의 기본 크기 계산을 유지해요.
아래 CollectionView 크기 계산 예제는 제안된 폭과 항목 수로 전체 높이를 계산해요. 폭이 없으면 nil을 반환하므로 SwiftUI가 기본 방식으로 크기를 계산해요. 배치와 높이 계산은 같은 CollectionLayoutMetrics를 사용해요. 다음 코드는 해당 화면의 크기 계산 호출을 발췌했어요.
import SwiftUI
import UIComposable
struct FittingCollectionViewDemo: View {
@State private var itemCount = 7
@State private var width = 260.0
var body: some View {
DemoCollectionView
.composable(
update: { collectionView in
collectionView.isScrollEnabled = false
collectionView.applyItems(Array(1...itemCount))
},
sizeThatFits: { proposal, collectionView in
guard let width = proposal.width, 0 < width else {
return nil
}
return CGSize(
width: width,
height: CollectionLayoutMetrics.height(
for: width,
itemCount: collectionView.itemCount
)
)
}
)
.frame(width: CGFloat(width))
}
}sizeThatFits:는 SwiftUI의 배치 과정에서 반복 호출될 수 있어요. 크기 계산만 수행하고 상태를 바꾸지 않아야 해요. Coordinator lifecycle과도 별도로 호출돼요.
Delegate처럼 별도 객체가 필요한 UIKit 컴포넌트는 UICoordinatedComposable을 채택해요. 컴포넌트가 Coordinator 생성과 UIKit별 연결 방법을 소유하고 Bridge는 lifecycle 호출 순서만 관리해요. ExampleApp의 CoordinatedDemoCollectionView는 Coordinator에서 dataSource와 delegate를 연결하고 선택 결과와 셀 표시 생명주기를 SwiftUI에 전달해요. 다음 코드는 해당 화면의 호출을 발췌했어요.
import SwiftUI
import UIComposable
struct CoordinatedCollectionViewDemo: View {
@State private var itemCount = 18
@State private var selectedItem: Int?
@State private var displayedItem: Int?
@State private var endedDisplayingItem: Int?
var body: some View {
CoordinatedDemoCollectionView
.composable { collectionView in
collectionView.items = Array(1...itemCount)
collectionView.onSelection = { selectedItem = $0 }
collectionView.onWillDisplay = { displayedItem = $0 }
collectionView.onDidEndDisplaying = { endedDisplayingItem = $0 }
}
.frame(height: 280)
}
}SwiftUI identity마다 UIKit 객체와 Coordinator가 한 번 생성돼요.
- 최초 표시에서 UIKit 객체 생성,
makeCoordinator(),connect(coordinator:), 외부update,update(coordinator:)순으로 호출돼요. - 이후 갱신에서 실제로 표시 중인 UIKit 인스턴스에 외부
update를 적용한 뒤 같은 Coordinator로update(coordinator:)를 호출해요. - identity가 사라질 때 실제로 표시 중인 UIKit 인스턴스와 같은 Coordinator로
disconnect(coordinator:)를 호출해요.
identity가 바뀌면 새 Coordinator가 생성될 수 있어요. 외부 update와 update(coordinator:)는 반복 호출에도 같은 결과를 내도록 구현해야 해요.
composable(update:)은 호출 지점의 제네릭 제약과 관계없이 실제 UIKit 객체가 UICoordinatedComposable을 채택했는지 확인해요. 다음처럼 UIComposable 제약만 사용해도 Coordinator lifecycle을 실행해요.
@MainActor
func composableView<T>(_ content: T.Type) -> some View
where T: UICollectionView & UIComposable {
content.composable()
}기본 UIComposable 타입은 외부 update만 적용하고, UICoordinatedComposable 타입은 makeCoordinator, connect, update(coordinator:), disconnect를 함께 실행해요.
ExampleApp은 UICollectionView와 UICollectionViewController의 기본, 크기 계산, Coordinator, Coordinator와 크기 계산 경로를 각각 보여줘요. 생명주기 화면에서는 UIKit 객체의 생성 수, 제네릭 제약과 무관한 Coordinator 연결, 강한 참조 해제를 확인할 수 있어요. 모두 아홉 화면이며 저장소의 UIComposable 패키지를 상대 경로로 사용해요.
Xcode에서 Examples/ExampleApp/ExampleApp.xcodeproj를 열고 ExampleApp scheme과 iOS Simulator를 선택해 실행할 수 있어요. 실행 없이 빌드만 확인하려면 저장소 루트에서 다음 명령을 사용해요.
xcodebuild -project Examples/ExampleApp/ExampleApp.xcodeproj \
-scheme ExampleApp \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO build기본 화면에서는 항목 수를 바꿔 갱신을 확인하고, 크기 계산 화면에서는 폭을 조절해 높이 변화를 확인해요. Coordinator 화면에서는 항목 선택과 셀 표시 시작 및 종료가 SwiftUI 상태로 전달되는지 확인해요.