2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UIHostingControllerを用いたSwiftUIとUIKitの統合実装とMVVMアーキテクチャによるGitHub検索アプリの構築

Last updated at Posted at 2025-07-22

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のMainViewUIHostingControllerでラップし、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」お待ちしています!

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?