- Overview
- Installation
- Views and Composition
- State Management & Reactive Data Flow
- Modern Collection and Table Views
- Advanced View Structure
- Navigation & Auto-Routing
- Author
- Contribution
- License
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.
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 (
PropertyandSignal) 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
UICollectionViewandUITableViewlayouts using native Swift Diffable Data Sources with a few lines of code.
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")
]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.
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)
}
}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.
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.
Construkt introduces two core primitives:
Property<T>— A state container that holds a value and emits updates on change (like@PublishedorBehaviorRelay).Signal<T>— A transient event emitter that broadcasts values to subscribers without holding state (likePublishRelay).
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()
}
}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
CancelBagdirectly into instantiated UIViews. When a UIView deallocates, any reactive observation modifying that view is automatically torn down.
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 anyViewBindingintoAnyViewBinding<T>
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 ViewBindingsConstrukt 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
Building lists in UIKit traditionally requires massive boilerplates, DTO mappings, and manual reloadData() calls. Construkt abstracts this all away.
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
}
})
}
}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)
))
}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)
}
}
}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.
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 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.
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 = progressConstrukt 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.
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)
// ...
}
}
}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()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 withchildrenarray andstart(). Overridestart()to set up the initial screen.RouteHandlingCoordinator— protocol combiningConstruktCoordinator+RouteReceiving. Requires arouterproperty andcanReceive()to handle events.ConstruktRouter— protocol for navigation actions (push,pop,present,dismiss,setRoot). UseDefaultRouteras the concrete implementation.receiver: self— thereceiverparameter binds the coordinator to the pushed view controller via associated objects, so route events from that screen flow back to the correct coordinator.
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)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.
Previously, this was Builder. Originally created by Michael Long.
- Github: Builder
Continued and improved as Construkt and maintained by Bayu Kurniawan.
- GitHub: @thatswiftdev
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.
Construkt is available under the MIT license. See the LICENSE file for more info.
