UIHostingControllerを用いたSwiftUIとUIKitの統合実装とMVVMアーキテクチャによるGitHub検索アプリの構築
iOS 13以降、SwiftUIが登場してからUI開発が大きく変わりましたが、まだまだUIKitとSwiftUIを併用している現場は多いのではないでしょうか?
今回は、SwiftUIをUIHostingControllerでUIKitに組み込みつつ、MVVMアーキテクチャでGitHub検索アプリを構築する方法を紹介します。
🎯 完成イメージ
- UIKitベースの
ViewController
- SwiftUIで作られた
MainView
- MVVM構成
- GitHub APIからリポジトリを検索してリスト表示
📦 使用技術
- SwiftUI
- Combine
- UIKit(UIHostingController)
- GitHub REST API
🧱 構成
->> tree
.
├── AppDelegate.swift
├── SceneDelegate.swift
├── Model
│ ├── Api
│ │ ├── GitHubClient.swift
│ │ ├── GitHubClient+.swift
│ │ └── Response
│ │ └── Repository.swift
│ └── MainObservable.swift
├── MVVM
│ └── ViewModel.swift
└── View
├── MainView.swift
└── ViewController.swift
🔍 検索画面 (SwiftUI)
struct MainView: View {
@ObservedObject var model: MainObservable = .init()
@State private var keyword = ""
var body: some View {
NavigationView {
VStack {
HStack {
TextField("検索ワード", text: $keyword)
.textFieldStyle(PlainTextFieldStyle())
.padding()
Button("検索") {
Task {
model.textSubject.send(keyword)
}
}
.padding()
}
List(model.items) { repository in
Text(repository.fullName)
}
.listStyle(PlainListStyle())
}
.navigationTitle("GitHub")
}
}
}
🎮 ViewController (UIKit)
SwiftUIのMainView
をUIHostingController
でラップし、MVVMの入出力を紐付けています。
import Combine
import SwiftUI
import UIKit
final class ViewController: UIViewController {
private let viewModel: ViewModelType = ViewModel()
private let mainView = MainView()
private var cancellable = Set<AnyCancellable>()
// MARK: Lifecycle
override func loadView() {
super.loadView()
mainView
.model
.textSubject
.sink { [self] text in
viewModel.input.search(text)
}.store(in: &cancellable)
viewModel
.output
.repositorySubject
.sink { [weak self] repositories in
guard let self else { return }
mainView.model.items = repositories
}.store(in: &cancellable)
setup()
}
// MARK: Private function
private func setup() {
let hostViewController = UIHostingController(rootView: mainView)
addChild(hostViewController)
view.addSubview(hostViewController.view)
hostViewController.view.frame = view.bounds
hostViewController.didMove(toParent: self)
}
}
🧠 ViewModel (MVVMの核)
import Combine
import Foundation
import SwiftUI
@MainActor
protocol ViewModelInput: AnyObject {
func search(_ query: String)
}
protocol ViewModelOutput: AnyObject {
var repositorySubject: PassthroughSubject<[Repository], Never> { get }
}
protocol ViewModelType: AnyObject {
var input: ViewModelInput { get }
var output: ViewModelOutput { get }
}
final class ViewModel: ViewModelType {
var input: ViewModelInput { self }
var output: ViewModelOutput { self }
fileprivate var githubClient = GitHubClient.liveValue
fileprivate var _repositorySubject: PassthroughSubject<[Repository], Never> = .init()
}
extension ViewModel: ViewModelInput {
func search(_ query: String) {
Task {
guard let items = try? await githubClient.search(query) else { return }
_repositorySubject.send(items)
}
}
}
extension ViewModel: ViewModelOutput {
var repositorySubject: PassthroughSubject<[Repository], Never> {
_repositorySubject
}
}
🧪 Model (API通信)
struct GitHubClient {
var search: (String) async throws -> [Repository]
enum Error: Swift.Error, Equatable {
case decodingError
case networkError
}
}
extension GitHubClient {
static let liveValue = GitHubClient { query in
guard
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://api.github.com/search/repositories?q=\(encoded)")
else {
throw Error.networkError
}
let (data, _) = try await URLSession.shared.data(from: url)
guard let result = try? JSONDecoder().decode(SearchResult.self, from: data) else {
throw Error.decodingError
}
return result.items
}
struct SearchResult: Decodable {
let items: [Repository]
}
}
💡 おまけ:ObservableObject
final class MainObservable: ObservableObject {
@Published var textSubject: PassthroughSubject<String, Never> = .init()
@Published var items: [Repository] = []
}
✅ まとめ
- UIKitアプリにSwiftUIを組み込むなら
UIHostingController
が便利 - SwiftUI側は状態管理を
ObservableObject
で行う - MVVMで責務を明確に分けることで、拡張性やテスト性が高まる
- サンプルをgithub: yamagishi-tbe
MVVMSampleにコミットしました
🙌 最後に
UIKitとSwiftUIのハイブリッド構成で悩んでいる方、段階的にSwiftUIに移行したい方には特におすすめのパターンです!
気に入ったら「LGTM」お待ちしています!