Skip to content

MainActorDev/Construkt

Repository files navigation

Construkt: A Declarative UIKit Framework

Construkt Hero Banner

Table of Contents

Overview

Construkt lets you build UIKit-based user interfaces using a modern, declarative syntax identical to SwiftUI.

It brings the joy of declarative composition and reactive data flow to legacy UIKit projects, making it possible to build dynamic, state-driven interfaces without Storyboards, NIBs, or Auto Layout boilerplate.

LabelView($title)
    .color(.red)
    .font(.title1)

By leveraging Swift's ResultBuilder pattern, Construkt composes native UIView hierarchies under the hood. You get the concise, readable syntax of SwiftUI while retaining the full power, predictability, and infinite customizability of UIKit.

Why Construkt?

While SwiftUI is the future, many modern apps still maintain extensive UIKit codebases. Integrating SwiftUI via UIHostingController can be heavy and sometimes rigid.

Construkt solves this by being 100% UIKit.

  • Native Reactive Core: Construkt brings its own lightweight reactive primitives (Property and Signal) built natively with async/await and GCD. No external RxSwift or Combine dependencies required, though integration bridges are provided.
  • Zero Auto Layout Boilerplate: Stacks (VStackView, HStackView, ZStackView) handle all the constraint logic for you natively.
  • Modern CollectionViews: Build fully asynchronous UICollectionView and UITableView layouts using native Swift Diffable Data Sources with a few lines of code.

Installation

Construkt is distributed as a Swift Package and requires Xcode 16+ and Swift 6 (with backwards compatibility for Swift 5.9 language modes).

Minimum SDK Requirements:

  • iOS 14.0+
// Package.swift
dependencies: [
    .package(url: "https://github.com/MainActor-dev/Construkt.git", from: "1.0.0")
]

Agentic Coding with Construkt

If you are using AI coding assistants (like Antigravity, Cursor, Windsurf, or GitHub Copilot), you can use the provided SKILL.md file to help your agent write high-quality Construkt code.

Simply inform your agent to read the SKILL.md file at the root of the repository. This file contains comprehensive guidelines, component references, and best practices for writing declarative UIKit with Construkt.


Views and Composition

In Construkt, screens are composed of views inside views using familiar structuring.

struct PosterCell: ViewBuilder {
    let movie: Movie
    
    var body: View {
        VStackView(spacing: 8) {
            ImageView(url: movie.posterURL)
                .shimmerable(true)
                .contentMode(.scaleAspectFill)
                .backgroundColor(.darkGray)
                .cornerRadius(8)
                .clipsToBounds(true)
                .height(180)
            
            VStackView(spacing: 4) {
                LabelView(movie.title)
                    .font(.systemFont(ofSize: 14, weight: .semibold))
                    .color(.white)
                    .numberOfLines(1)
                    .shimmerable(true)
                
                LabelView("Adventure") // Placeholder genre
                    .font(.systemFont(ofSize: 12))
                    .color(.gray)
                    .shimmerable(true)
            }
            .alignment(.leading)
        }
        .clipsToBounds(true)
    }
}

Custom Components

Creating reusable components that can accept standard Construkt modifiers (like padding, sizing, etc.) is as simple as defining a struct that conforms to ModifiableView.

import UIKit

public class _CircleView: UIView {
    public override func layoutSubviews() {
        super.layoutSubviews()
        layer.cornerRadius = min(bounds.width, bounds.height) / 2
        clipsToBounds = true
    }
}

public struct CircleView: ModifiableView {
    public let modifiableView = _CircleView()
    
    public init() {
        modifiableView.translatesAutoresizingMaskIntoConstraints = false
        modifiableView.backgroundColor = .clear
    }
}

Any Construkt ViewBuilder protocol conformance generates an underlying set of standard UIView elements by simply calling .build().

let view: UIView = PosterCell(movie: movie).build()

This structural approach encourages creating small, testable, highly-reusable interface components exactly like SwiftUI.


State Management & Reactive Data Flow

Construkt does not diff and reconstruct the entire view tree on every state change like SwiftUI. Instead, it relies on explicit, highly-efficient Reactive Bindings.

The Reactive Primitives

Construkt introduces two core primitives:

  1. Property<T> — A state container that holds a value and emits updates on change (like @Published or BehaviorRelay).
  2. Signal<T> — A transient event emitter that broadcasts values to subscribers without holding state (like PublishRelay).
class ProfileViewModel {
    @Variable var name: String = "John Doe" // Uses Property<String> under the hood
    let onProfileUpdated = Signal<Void>()

    func refresh() {
        name = "Jane Doe"
        onProfileUpdated.send()
    }
}

Binding to Views

Construkt provides an extensive set of View Modifiers specifically designed for data binding. Use the $ prefix to access the reactive projection of a Variable.

LabelView($viewModel.name) // Automatically updates the label when the name changes
    .font(.body)

ButtonView("Save")
    .hidden(bind: $viewModel.isSaving)

ActivityIndicator()
    .hidden(bind: $viewModel.isLoading.map { !$0 }) // Supports operators like map, filter, etc.

If you need a completely custom binding, use onReceive:

ImageView()
    .onReceive($viewModel.profileImage) { context in
        context.view.image = context.value
    }

Memory Management: Cancellables strings are handled for you. Construkt injects a hidden CancelBag directly into instantiated UIViews. When a UIView deallocates, any reactive observation modifying that view is automatically torn down.

Native Operators

Construkt's native binding system includes a rich suite of built-in operators so you don't need external reactive frameworks for everyday logic:

  • .map, .compactMap
  • .filter, .skip
  • .debounce(for:on:), .throttle(for:latest:on:)
  • .merge(with:), .combineLatest(_:_:)
  • .distinctUntilChanged(), .removeDuplicates(by:)
  • .scan(_:_:)
  • .eraseToAnyViewBinding() — type-erase any ViewBinding into AnyViewBinding<T>

Combine & RxSwift Integration

If your app already uses Combine or RxSwift, Construkt is fully agnostic. Simply import the corresponding bridging files:

import Construkt
import Combine // Import bridging extensions

let publisher = CurrentValueSubject<String, Never>("Combine Data")

LabelView(publisher) // Construkt treats Combine Publishers as native ViewBindings

Included UI Components

Construkt provides declarative wrappers for most standard UIKit components:

  • Text & Controls: LabelView, ButtonView, TextField, TextEditor, Toggle, Slider, Stepper
  • Layout & Spacing: VStackView, HStackView, ZStackView, Screen, SpacerView, DividerView
  • Visual & Indicators: ImageView, BlurView, LinearGradient, ProgressView, ActivityIndicator, CircleView

Modern Collection and Table Views

Building lists in UIKit traditionally requires massive boilerplates, DTO mappings, and manual reloadData() calls. Construkt abstracts this all away.

Table Views

TableView accepts a DynamicItemViewBuilder to declaratively map data to cells — no delegates, no data sources.

struct MainUsersTableView: ViewBuilder {
    
    let users: [User]
    
    var body: View {
        TableView(DynamicItemViewBuilder(users) { user in
            TableViewCell {
                MainCardView(user: user)
            }
            .accessoryType(.disclosureIndicator)
            .onSelect { context in
                context.push(DetailViewController(user: user))
                return false
            }
        })
    }
}

Dynamic Collection Views

CollectionView leverages DiffableDataSources and supports multi-section layouts with headers, footers, and orthogonal scrolling — all via a AnySection-based ResultBuilder syntax.

CollectionView {
    AnySection(id: "trending", items: movies, header: Header { LabelView("Trending Now").font(.title1) }) { movie in
        AnyCell(movie, id: movie.id) { movieData in
            MoviePosterCell(movie: movieData)
        }
    }
    .layout(.horizontalOrthogonal(
        width: .fractionalWidth(0.8), 
        height: .fractionalHeight(1.0)
    ))
}

Static Collection Views

You can also build statically-defined declarative collections (e.g., Settings menus) by listing explicit AnyCell components within a AnySection:

CollectionView {
    AnySection(id: "settings", header: Header { LabelView("General") }) {
        AnyCell("Notifications", id: "notifications") { title in
            SettingsRowView(title: title)
        }
        AnyCell("Privacy", id: "privacy") { title in
            SettingsRowView(title: title)
        }
    }
}

Shimmer Loading States

Building sophisticated loading UIs is built-in natively:

AnySection(id: "popular", items: movies) { movie in
    AnyCell(movie, id: movie.id) { movieData in 
        MoviePosterCell(movie: movieData) 
    }
}
.shimmer(count: 5, when: $viewModel.isLoading) {
    MoviePosterCell(movie: .placeholder)
}

When isLoading is true, Construkt automatically generates 5 shimmer placeholder geometries based on your ViewBuilder structure and animates a shimmer gradient across them. When the data loads, it cross-dissolves them back to your actual fetched data natively.


Advanced View Structure

While stacks are primary, Construkt exposes powerful layout control through direct anchors, offsets, and geometry modifiers.

ZStackView {
    ImageView(backdropImage)
        .contentMode(.scaleAspectFill)
    
    // Auto-calculating overlay gradients
    LinearGradient(colors: [.black.withAlphaComponent(0), .black])
    
    LabelView("Featured Content")
        .color(.white)
        .position(.bottomLeft)
        .margins(h: 20, v: 20)
}
.height(300)
.clipsToBounds(true)

Unlike SwiftUI, you don’t have to fight the layout system. A ViewBuilder is just generating traditional UIView nodes. You can access the UIKit primitives at any point using with:

LabelView("Direct UIKit Access")
    .with { label in
        // 'label' is guaranteed to be a UILabel
        label.shadowColor = .lightGray
        label.shadowOffset = CGSize(width: 1, height: 1)
    }

Screen Layout Container

Screen is a high-level layout component that provides a standard page architecture with content and navigation bar slots. It replaces manual ZStackView + .position(.top) boilerplate:

struct HomeView: ViewConvertable {
    func asViews() -> [View] {
        Screen {
            CollectionView {
                heroSection
                popularSection
            }
            .onScroll { scrollView in
                scrollBinding.offset = scrollView.contentOffset.y
            }
        }
        .navigationBar {
            HomeNavigationBar(
                scrollOffset: scrollBinding.$offset.eraseToAnyViewBinding()
            )
        }
        .backgroundColor(UIColor("#0A0A0A"))
        .asViews()
    }
}

The Screen component handles Z-stacking and pinning automatically. Each screen can provide a completely distinct navigation bar UI while the layout structure remains consistent.

Scroll-Driven Helpers

Construkt includes a CGFloat extension for normalizing scroll offsets into 0…1 progress values, useful for scroll-driven animation effects:

let progress = scrollOffset.scrollProgress(over: 100) // 0.0 → 1.0 over 100pt
navBarBackground.alpha = progress

Navigation & Auto-Routing

Construkt includes a flexible navigation engine with two approaches: ConstruktRouteHandler for centralized route handling, and ConstruktCoordinator for coordinator-tree architectures. Both use UIKit's responder chain so views never hold direct references to navigation logic.

Route Handler (Recommended)

Define routes as an enum, then create a centralized handler that manages all navigation:

enum AppRoute: Codable {
    case movieDetail(movieId: String)
    case movieList(title: String, genreId: Int?)
    case search
}

final class AppRouteHandler: ConstruktRouteHandler<AppRoute> {
    override func handle(_ route: AppRoute, sender: Any?) -> Bool {
        open(route, animated: true)
        return true
    }

    func open(_ route: AppRoute, animated: Bool = true) {
        switch route {
        case .movieDetail(let id):
            let screen = MovieDetailView(movie: movie)
                .onReceiveRoute(MovieDetailRoute.self) { [unowned self] route in
                    switch route {
                    case .back:
                        navigationController?.popViewController(animated: true)
                        return true
                    case .similarMovie(let movie):
                        open(.movieDetail(movieId: String(movie.id)))
                        return true
                    }
                }
                .toPresentable()
            router.push(screen, animated: animated, receiver: self)
        case .search:
            router.push(SearchViewController(), animated: animated, receiver: self)
        // ...
        }
    }
}

Inline Route Handling with onReceiveRoute

Use .onReceiveRoute to attach route handlers directly when constructing screens:

HomeView()
    .onReceiveRoute(HomeRoute.self) { [unowned self] route in
        switch route {
        case .movieDetail(let id): open(.movieDetail(movieId: id))
        case .search: open(.search)
        }
        return true
    }
    .toPresentable()

Coordinator Pattern

For larger apps with complex navigation hierarchies, Construkt provides a full Coordinator tree. Coordinators own navigation logic and form a parent-child hierarchy via store()/free():

final class HomeCoordinator: BaseCoordinator, RouteHandlingCoordinator {
    typealias Event = HomeRoute
    let router: ConstruktRouter

    init(router: ConstruktRouter) {
        self.router = router
    }

    override func start() {
        let homeVC = HomeView(viewModel: viewModel).toPresentable()
        router.setRoot(homeVC, hideBar: true, animated: false, receiver: self)
    }

    func canReceive(_ event: HomeRoute, sender: Any?) -> Bool {
        switch event {
        case .movieDetail(let id):
            let detailVC = MovieDetailView(movie: movie).toPresentable()
            router.push(detailVC, animated: true, receiver: self)
            return true
        case .search:
            let searchVC = SearchViewController()
            router.push(searchVC, animated: true, receiver: self)
            return true
        }
    }
}

Key concepts:

  • BaseCoordinator — base class with children array and start(). Override start() to set up the initial screen.
  • RouteHandlingCoordinator — protocol combining ConstruktCoordinator + RouteReceiving. Requires a router property and canReceive() to handle events.
  • ConstruktRouter — protocol for navigation actions (push, pop, present, dismiss, setRoot). Use DefaultRouter as the concrete implementation.
  • receiver: self — the receiver parameter binds the coordinator to the pushed view controller via associated objects, so route events from that screen flow back to the correct coordinator.

Declarative Routing

Attach navigation intent directly to collection view sections using two explicit modifiers:

Modifier Signature Purpose
.onSelect (T) -> Void Imperative side-effects (analytics, ViewModel calls)
.onRoute (T) -> E Declarative routing — auto-bubbles via responder chain
.onRoute (T) -> E? Optional variant — routes only when non-nil
// Declarative: returns an event, automatically routed via the responder chain
AnySection(id: "popular", items: movies) { movie in
    AnyCell(movie, id: movie.id) { movie in PosterCell(movie: movie) }
}
.onRoute { (movie: Movie) in
    AppRoute.movieDetail(movieId: String(movie.id))
}

Any UIView can also trigger navigation directly using UIResponder.route():

// From a tap gesture — routes from the sender view's responder chain
.onTapGesture { context in 
    context.view.route(HomeRoute.search, sender: nil) 
}

// Or declaratively on any view
ImageView(UIImage(systemName: "magnifyingglass"))
    .onRoute(AppRoute.search)

ViewConvertable

Screens are pure structs that produce declarative view hierarchies. The framework wraps them in LifecycleHostController for UIKit integration:

struct HomeView: ViewConvertable {
    let viewModel: HomeViewModel

    func asViews() -> [View] {
        Screen {
            CollectionView {
                AnySection(id: "movies", items: viewModel.movies) { movie in
                    AnyCell(movie, id: movie.id) { movie in PosterCell(movie: movie) }
                }
                .onRoute { (movie: Movie) in
                    HomeRoute.movieDetail(movieId: String(movie.id))
                }
            }
        }
        .navigationBar {
            HomeNavigationBar(scrollOffset: scrollBinding.$offset.eraseToAnyViewBinding())
        }
        .onHostDidLoad { viewModel.load() }
        .asViews()
    }
}

Events bubble up the UIKit responder chain → reach the LifecycleHostController → handled by the registered onReceiveRoute handler or ConstruktRouteHandler. No direct references between views and navigation logic.


Author

Previously, this was Builder. Originally created by Michael Long.

Continued and improved as Construkt and maintained by Bayu Kurniawan.


Contribution

Contributions are welcome! If you have ideas, bug reports, or want to add new features, feel free to open an issue or submit a pull request.


License

Construkt is available under the MIT license. See the LICENSE file for more info.

About

Build native iOS apps using the clean, modern syntax of SwiftUI, while keeping the full power and reliability of UIKit.

Topics

Resources

License

Stars

Watchers

Forks

Contributors

Languages