Skip to the content.

既存の UIKit のプロジェクトで SwiftUI を導入する

概要/目的

SwiftUI でのクリーンアーキテクチャ

SwiftUI Architecture

Presentation 層

View

import SwiftUI

struct ContentView: View {
    @EnvironmentObject private var interactor: Interactor
    @EnvironmentObject private var presentation: Presentation

    var body: some View {
        ZStack {
            Color(.secondary)

            Text("Hello, World!").padding()

            if let state = presentation.sheetState {
                state.content
                    .transition(.opacity.animation(.easeInOut))
            }
        }
        .onAppear(perform: interactor.fetch)
    }

Presentation

import SwiftUI

class Presentation: ObservableObject {
    enum SheetState {
        case loading
        case detail(entity: Entity)

        @ViewBuilder
        var content: some View {
            switch self {
            case .loading:
                IndicatorView()
            case .detail(let entity):
                DetailView(entity: entity)
            }
        }
    }

    @Published var sheetState: SheetState? = .none
    @Published var completed: Bool = false
}

Business Logic 層

RepositoryProtocol

import Foundation
import Combine

protocol RepositoryProtocol {
    func fetch() -> AnyPublisher<[Entity], Error>
    func create(with parameter: Parameter) -> AnyPublisher<Entity, Error>
}

Interactor

import Foundation
import Combine

class Interactor: ObservableObject {
    @Published var state: State
    let repository: RepositoryProtocol

    private var cancellables: Set<AnyCancellable> = []

    init(repository: RepositoryProtocol) {
        self.repository = repository
        self.state = .init()
    }

    func fetch() {
        repository.fetch()
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in self?.state.datasource = $0 }
            .store(in: &cancellables)
    }

    func toggleSelected(id: Entity.ID) {
        if state.selections.contains(id) {
            state.selections.remove(id)
        } else {
            state.selections.insert(id)
        }
    }
}

State

import Foundation

struct State {
    var datasource: [String: [Entity]]
    var selections: Set<Entity.ID> = []
}

Entity

import Foundation

struct Entity: Codable, Identifiable {
    typealias ID = String
    var id: ID
}

Data Access

Repository

struct MockRepository: RepositoryProtocol {
    func fetch() -> AnyPublisher<[Entity], Error> {
        let response: [Entity] = [.init(id: "foo"), .init(id: "bar"), .init(id: "baz")]
        Future { promise in
            promise(.success(response))
            // promise(.failure(APIServerError.badServerResponse))
        }
        .eraseToAnyPublisher()
    }

    func create(with parameter: Parameter) -> AnyPublisher<Entity, Error> {
        Future { promise in
            promise(.success(Entity(id: "created")))
            // promise(.failure(APIServerError.badServerResponse))
        }
        .eraseToAnyPublisher()
    }
}

UIKit のプロジェクトに SwiftUI を導入する

SwiftUI in UIKit Architecture

Swift Package Manager

UIViewController / UIHostingControler

import Combine
import SwiftUI
import UIKit

import ViewModule

final class ViewController: UIViewController {
    struct Dependency {
        var repository: RepositoryProtocol
    }

    private var dependency: Dependency
    private var cancellables: Set<AnyCancellable> = []
    @IBOutlet private var containerView: UIView!

    init?(coder: NSCoder, dependency: Dependency) {
        self.dependency = dependency
        super.init(coder: coder)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        let interactor = Interactor(repository: dependency.repository)
        let presentation = Presentation()
        let rootView = ContentView()
            .environmentObject(interactor)
            .environmentObject(presentation)
        let viewController = UIHostingController(rootView: rootView)

        addChild(viewController)
        viewController.view.frame = self.containerView.bounds
        viewController.view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
        self.containerView.addSubview(viewController.view)
        viewController.didMove(toParent: self)

        presentation.$completed
            .sink { [weak self] in if $0 { self?.dismiss(animated: true) } }
            .store(in: &self.cancellables)
    }
}