iOS Live Preview — "Xcode-style," scoped honestly
A VS Code extension: write a small Swift UI description on the left, see it
rendered live in a phone-shaped canvas on the right, updating as you type —
no explicit save required.
This does not use Xcode or the real Swift compiler at all, and it can't:
SwiftUI/UIKit are Apple-platform-only frameworks (not part of the
open-source Swift toolchain even on Windows), so nothing on Windows can
compile or run them. Instead, this extension has its own small hand-written
interpreter that understands two things:
- A real, partial SwiftUI syntax subset (
struct X: View { var body: some View { ... } }) — see "Real SwiftUI subset" below. Code using only
what's listed there is genuine SwiftUI: it would also compile and run
correctly in real Xcode on a Mac.
- A small invented imperative subset (
struct X: TKScreen { func build() -> UIView { ... } }) — this one is not real Swift/UIKit at
all, just an API shaped similarly enough to teach the same concepts. See
"Frame subset" below. This was this project's original approach, kept for
backward compatibility.
Either way, there's no compiler invoked, so a full parse + render takes well
under a millisecond instead of the 1-3+ seconds a real swift build needs
just to start up — that's what makes true per-keystroke live updates
possible at all.
Phone chrome (v0.8.3)
The preview now looks like a real iPhone: a live status bar (clock that
ticks with your computer's time, cellular bars, a Wi-Fi icon that dims when
your machine goes offline, and a battery that reflects your real charge level
and turns green while charging), a Dynamic Island, side buttons, and a home
indicator. The SwiftUI content is laid out inside the iOS "safe area" so
nothing sits under the status bar. This is purely visual, and doesn't change
how your code is interpreted.
Vector SF Symbols (v0.8.4, expanded in v0.9.0)
Image(systemName:) symbols render as vector icons so they follow
.foregroundStyle, scale with .font, and pick up the TabView tint. Three are
hand-drawn to match Apple's (clock.arrow.circlepath, clock.badge.xmark,
plusminus.circle.fill); about 500 more names map onto open-source icons
(Lucide, ISC license). The mapping lives in the SYM_MAP table inside
media/preview.html.
SwiftUI library breadth (v0.9.0)
v0.9 widens the library the preview understands (the vocabulary of views and
modifiers). It does not add a Swift logic engine (see "What's not here"
below). New in this release:
Expressions. Anywhere a number, string, Bool or condition is expected you
can now write real Swift expressions: arithmetic (+ - * / %), ??, ternaries,
comparisons, min/max/abs/round/sqrt/..., String(format:), string
methods (uppercased(), count, contains(...), ...), array methods
(map, filter, sorted, reduce, first, joined, ...), Date(), and
full-expression string interpolation ("\(items.count) item\(items.count == 1 ? "" : "s")").
ForEach / List loop over any array or range, including enumerated(),
indices, $0, and id: \.self.
Views. Grid / GridRow / LazyVGrid / LazyHGrid (with GridItem),
Menu, DatePicker (compact and graphical), ColorPicker, Gauge,
GroupBox, DisclosureGroup, LabeledContent, AsyncImage, GeometryReader,
SecureField, TextEditor, Ellipse, Capsule, UnevenRoundedRectangle, all
four gradients (LinearGradient, RadialGradient, AngularGradient,
EllipticalGradient), placeholders for Map / VideoPlayer / Canvas / Chart,
ShareLink, EditButton, ControlGroup, ViewThatFits, ScrollViewReader.
Text("a") + Text("b").bold() builds one text with differently styled runs.
Pickers render as menu, segmented, inline or wheel.
Modifiers. Colors, gradients and materials everywhere a color is accepted;
.overlay / .background with views; .stroke / .strokeBorder / .trim;
.mask; filters (brightness, contrast, saturation, grayscale,
hueRotation, colorInvert); .frame(minWidth:maxWidth:...:alignment:);
.aspectRatio, .resizable, .scaledToFit/Fill; .fontDesign,
.monospacedDigit, .tracking, .minimumScaleFactor, .truncationMode;
.buttonStyle (.bordered, .borderedProminent, .plain), .controlSize,
.textFieldStyle, .labelStyle, .toggleStyle, .progressViewStyle,
.gaugeStyle, .datePickerStyle, .tabViewStyle(.page); .badge;
.listStyle, .listRowBackground, .listRowSeparator,
.scrollContentBackground; .searchable; .toolbar { ToolbarItem(...) };
.navigationBarTitleDisplayMode; and presentations that really appear:
.sheet, .fullScreenCover, .popover, .alert, .confirmationDialog
(driven by your @State Bool, and dismiss() works). Modifiers on a
ForEach / Group apply to every view it produces, and modifiers on a
container (.foregroundStyle, .font, .tint, ...) flow down to the views
inside it. Over 150 more real-world modifiers (accessibility, gestures,
animations, ...) are accepted without error so pasted code still parses.
Structure. Your own var header: some View { ... } properties and
func row(_ t: String) -> some View { ... } functions work (including inside
reusable components). A reusable component can have its own @State.
; can separate views on one line, and tuples like (x: 1, y: 0) parse.
Symbols. About 500 SF Symbol names (plus their .fill, .circle, ... variants)
are drawn as real vector icons that follow .foregroundStyle, .font and the
tab tint, instead of emoji. Names without a drawing fall back to a placeholder
square.
Look and feel. iOS-style switches, nav bars with large/inline titles and
toolbars, tab bars with badges, grouped lists and forms, alerts, action sheets
and bottom sheets, and a default 8-pt spacing in stacks (like SwiftUI).
What's not here yet (planned for v0.10, the "Swift logic engine").
struct models and enums, your own funcs that mutate state,
ObservableObject / @Observable, closures, Path and custom Shapes,
NavigationLink(value:) + navigationDestination, and .sheet(item:).
SwiftUI is enormous; this preview covers the parts students use in IT 271, not
literally every API.
How it works
you type in a .swift file
│ (every keystroke, ~80ms debounce)
▼
extension.ts reads the document text
│
▼
interpreter/lexer.ts → tokens (shared by both subsets)
interpreter/parser.ts → looks at "struct X: <Protocol>" and picks a mode:
Protocol === 'View' → 'flex' mode
anything else → 'frame' mode
then produces an AST (or a precise syntax error
with line/col)
│ │
▼ (frame mode) ▼ (flex mode)
interpreter/interpreter.ts sui/interpreter.ts
walks Stmt[] against walks the body Expr against
interpreter/runtime.ts sui/runtime.ts
→ RTNode tree (absolute frames) → SUINode tree (flex containers)
│ │
└──────────────┬───────────────────────┘
▼
postMessage → media/preview.html renders either tree:
'frame' → absolutely-positioned <div>s at explicit x/y/width/height
'flex' → real CSS flexbox (VStack/HStack → flex-direction
column/row, ZStack → CSS grid overlay) — this is what makes
SwiftUI-style auto-sizing/stacking behave correctly without
reimplementing a layout engine by hand
...inside a 390×844 iPhone-shaped frame either way
Errors surface two ways at once: as a red squiggle at the exact character in
your editor (via VS Code diagnostics), and in the preview panel's error bar.
Hover help
Hovering the mouse over a recognized symbol — @State, a $binding,
VStack, Button, .padding(), and the rest of the vocabulary below —
shows a short popup explaining what it does, the same way VS Code shows
built-in documentation for other languages. (VS Code's hover shows up on
plain mouse hover, no modifier key needed — a little simpler than Xcode's
Option+hover Quick Help.) This works anywhere in a .swift file, whether or
not the preview panel is open, and is purely explanatory — it doesn't affect
parsing or rendering. hoverProvider.ts has the full list of documented
symbols; hovering something outside that list (an ordinary variable name,
say) just shows nothing.
Autocomplete
Typing . or @ pops up a completion list, the same gesture real Xcode
uses — scoped to exactly what this preview implements, so anything you pick
is guaranteed to parse rather than trading one error for another. What you
get depends on where the cursor is:
- Right after
@ (@|) — State, the only property wrapper this preview
actually implements. Real SwiftUI has others (@Binding,
@ObservedObject, @EnvironmentObject, ...), but they're left off the
list deliberately rather than suggesting something that would just get
parsed-and-ignored — this preview only ever renders one view, so there's
nothing to bind or observe across.
- After a finished view/modifier chain (
Text("Hi").|) — the modifier list
(.padding(), .font(_:), .frame(width:height:), .clipShape(_:),
.disabled(_:), etc.), each inserted as a small snippet with placeholders
for its argument(s).
- Inside
Color(.|) — the UIKit system-color names (.systemGray5,
.systemBackground, .label, ...).
- Inside
.font(.|) — the named text styles (.title, .headline, ...)
plus .system(size:).
- Inside
.foregroundStyle(.|) / .foregroundColor(.|) / .background(.|)
/ .fill(.|), or after a bare = . (frame-mode property assignment) —
the plain color list (.blue, .gray, ...).
- Inside
.imageScale(.|) — .small / .medium / .large.
- Manually invoking completion (Ctrl+Space) inside
.clipShape(|) — the
shape constructors (Circle(), Rectangle(), Capsule(),
RoundedRectangle(cornerRadius:)), since those don't start with a dot.
This isn't a port of Apple's real SwiftUI API surface (hundreds of
overloads, a real type checker) — it's completionProvider.ts's heuristic
read of "what call is the cursor inside right now," scoped to this
preview's own supported vocabulary.
Real SwiftUI subset
import SwiftUI
struct ContentView: View {
@State private var count = 0
var body: some View {
VStack(spacing: 12) {
Text("Welcome to IT 271").font(.title).bold()
Text("Count: \(count)").font(.subheadline).foregroundStyle(.secondary)
Image(systemName: "globe").imageScale(.large).foregroundStyle(.tint)
Button("Tap me") {
count += 1
}
.padding()
.background(.blue)
.foregroundStyle(.white)
.cornerRadius(8)
}
.padding()
}
}
This is genuine SwiftUI syntax — every line above would also compile in real
Xcode. What's covered:
struct X: View { var body: some View { ... } } — body can be a single
view (with any number of chained modifiers and nested trailing-closure
children), OR more than one top-level statement in sequence (e.g. a
VStack { ... } followed by a sibling if condition { ... }), matching
real SwiftUI's implicit @ViewBuilder on body — more than one resulting
view is shown stacked vertically. Still no for loop directly in body
(use ForEach/List inside a container instead).
@State private var name = <literal> (or a plain var/let, with or
without a : Type annotation) declared alongside body — the starting
value must be a simple literal: a number, a string, true/false, or a
[String] array (@State private var history: [String] = []) — the only
array element type this preview supports. See "Interactivity (@State)"
below for what you can do with it. @AppStorage("key") var name = <literal>
is also accepted and behaves identically — its UserDefaults key is simply
ignored, since persistence across real app launches isn't meaningful for
an edit-time preview.
- Views:
VStack(spacing:), HStack(spacing:), ZStack() (rendered with
real CSS flexbox/grid, so auto-sizing and stacking behave correctly),
Text("...") (supports string interpolation of a @State/var/let
name, e.g. Text("Count: \(count)"), or a simple numeric arithmetic
expression over numbers/names inside \(...) — +, -, *, /, unary
-, and parens, e.g. Text("Remaining: \(total - index)") — not a full
Swift expression (no function calls, no member access like \(name.count),
no string concatenation)), Image(systemName: "...") (SF Symbol name only — a small built-in table maps common names
like globe, star.fill, heart.fill, house, gear, checkmark to an
emoji stand-in; anything else shows a generic ▢ placeholder, since real
SF Symbol glyphs aren't available outside Apple's platforms), Spacer(),
Button { action } label: { views } / Button(action: action) { views } —
a Button whose label is views (e.g. an Image(systemName:) with
.frame/.background/.clipShape) instead of a string,
Button("title") { action } — the action closure is now genuinely
interactive, see below.
Also: Toggle("title", isOn: $name), TextField("placeholder", text: $name), Slider(value: $name, in: low...high) (all genuinely interactive
— see "Two-way bindings ($name)" below), ForEach(0..<n) { i in ... } /
ForEach(0...n) { i in ... } (a numeric range only — see "Loops and lists"
below), List { ... } / List(0..<n) { i in ... } (same children rules as
a VStack, styled as a scrollable list), and Circle() / Rectangle() /
RoundedRectangle(cornerRadius:) (default to a 40×40 box unless given a
.frame(width:height:); colored with .fill(_:) or .foregroundStyle(_:)).
if <condition> { ... } / else if <condition> { ... } / else { ... }
as a view-builder entry — inside a container's trailing closure (a
VStack, HStack, ZStack, or List), or directly as one of body's
top-level statements (see above). <condition> supports comparisons
(count > 0, count == 0, name != "x"), a bare @State/var/let
boolean name (isOn), ! (!isOn), &&/|| combinations of those
(always left-to-right, no () grouping), and a [String] @State array's
.isEmpty / .count (!history.isEmpty, history.count > 0).
- Modifiers, chainable in any order:
.padding() (+16pt all edges),
.padding(_:) (a single number, all edges) or edge-specific —
.padding(.top, 40), .padding(.horizontal) (+16pt default),
.padding(.horizontal, 20), and likewise .leading, .trailing,
.bottom, .vertical, .all — each edge accumulates separately if you
chain more than one .padding(...) (e.g. .padding(.top, 40).padding(.horizontal) applies 40pt to the top and 16pt to left+right,
independently),
.font(.title) (and .largeTitle, .title2, .title3, .headline,
.body, .callout, .subheadline, .footnote, .caption, .caption2)
or .font(.system(size: 20)) / .font(.system(size: 20, weight: .bold, design: .rounded)) (a custom point size; weight: only distinguishes
bold-ish weights — .semibold/.bold/.heavy/.black — from regular;
design: is accepted but has no visual effect here, since no matching web
font is bundled),
.foregroundStyle(_:) / .foregroundColor(_:), .fill(_:) (shape color —
an alias for .foregroundStyle(_:)), .background(_:),
.cornerRadius(_:), .frame(width:height:) and .frame(maxWidth: .infinity, maxHeight: .infinity) (fills available space along that axis;
mixing a fixed width/height with maxWidth/maxHeight in the same
.frame(...) isn't supported — use two separate .frame(...) calls),
.bold(), .opacity(_:), .imageScale(.small/.medium/.large),
.clipShape(_:) — takes an actual shape value, i.e. Circle(),
Rectangle(), Capsule(), or RoundedRectangle(cornerRadius: 12) (or
the iOS 17 dot-shorthand .circle / .capsule / .rect; a capitalized
.Circle isn't valid Swift and produces an error explaining the fix),
.disabled(_:) — grays out and
stops taps/input on a control when its argument is true; the argument is a
full condition, using the same comparison/!/&&/|| grammar as if
(e.g. .disabled(count == 0)), not just a bare @State name, and
.listStyle(_:) — accepted on a List (e.g. .listStyle(.plain),
.listStyle(.insetGrouped)) but has no visual effect, since this preview's
List only ever renders one plain style, .multilineTextAlignment(.leading / .center / .trailing) — aligns a Text's content left/center/right,
.navigationBarTitleDisplayMode(_:) — accepted (e.g. .inline,
.large) but has no visual effect, since this preview already always
shows a large left-aligned title for the root screen and a small
centered one for any pushed screen, and .navigationTitle(_:) /
NavigationLink — see "Navigation" below.
- Colors:
.red, .orange, .yellow, .green, .mint, .teal, .cyan,
.blue, .indigo, .purple, .pink, .brown, .gray, .black,
.white, .clear, .primary, .secondary, .tint — or Color(...):
either a UIKit system-color name (Color(.systemGray5),
Color(.systemBackground), Color(.label), etc. — the common case for
card/background colors in tutorials) or an explicit
Color(red:green:blue:) triple with 0–1 components.
import SwiftUI (or any import X) is accepted and ignored; a trailing
#Preview { ... } / Preview { ... } block at the bottom of the file is
also just ignored (parsing stops once body is found), so leaving Xcode's
usual boilerplate in place is fine.
Interactivity (@State)
A Button's action closure is genuinely run when you click that button in
the preview panel — not just parsed and ignored. Try examples/CounterApp.swift.
Supported inside a Button { ... } closure, one statement per line:
name += <number>, name -= <number> — name must be a numeric @State.
name = <literal> — resets name to a number, string, true/false, or a
[String] array literal (e.g. history = []).
name.toggle() — flips a boolean @State.
name.append(<string>) — appends one string to the end of a [String]
@State array.
name.insert(<string>, at: <number>) — inserts one string into a
[String] @State array at the given index (e.g.
history.insert("Increased to \(count)", at: 0) to prepend a log entry).
name.removeAll() — empties a [String] @State array.
That's the full action grammar — arbitrary Swift statements, if, function
calls, and multi-step logic inside a Button's closure aren't supported.
State persists across keystrokes (typing elsewhere in the file won't reset a
counter you've already tapped), but re-parses fresh — using each
@State's own initial value — whenever that particular name's declaration
changes or disappears.
A faithful (not buggy) quirk worth knowing: a plain VStack with no
.frame(maxHeight: .infinity) sizes to fit its content and gets centered,
same as real SwiftUI — it does not automatically fill the screen, so
Spacer() inside an unstretched root VStack visibly does nothing. Add
.frame(maxWidth: .infinity, maxHeight: .infinity) (see above) to make it
fill the screen — once it does, its content re-centers in the extra space by
default, exactly like real SwiftUI, and Spacer() then does push things
apart as expected.
Two-way bindings ($name)
$name refers to a @State value for reading and writing — e.g. the
$isOn in Toggle("Dark Mode", isOn: $isOn). Three controls understand it,
and all three are genuinely interactive (drag/type/tap them right in the
preview and the bound @State updates live, the same way a Button's action
does):
Toggle("title", isOn: $boolName) — boolName must be a true/false
@State.
TextField("placeholder", text: $stringName) — stringName must be a
text (string) @State. Typing preserves your cursor position across the
live re-render.
Slider(value: $numberName, in: low...high) — numberName must be a
numeric @State; in: is optional and defaults to 0...1, matching real
SwiftUI.
$name is only meaningful in these three positions — it isn't a general
expression, so you can't do something like let x = $count.
Loops and lists
ForEach(0..<count) { i in ... } (half-open) or ForEach(0...count) { i in ... } (closed) repeats its body once per integer in the range, with i
(any name you choose) bound to the current value inside — e.g. ForEach(0..<3) { i in Text("Row \(i)") }. The range's bounds can be a literal or a numeric
@State/var/let name (so ForEach(0..<count) works). A range generating
more than 500 views is a (deliberate) error rather than a frozen preview.
ForEach also accepts a [String] @State array as its source, with a
required id: \.self — e.g. ForEach(history, id: \.self) { entry in Text(entry) }. id: \.self is required (not just accepted) because a plain
String isn't Identifiable in real Swift either — this matches real
SwiftUI's own requirement, not an extra restriction invented for this
preview; any id: keypath besides \.self isn't supported.
When you need the position as well as the element (to show a row number, or
to pass the index on to something else), iterate the array's .indices
instead of the array itself — e.g. ForEach(history.indices, id: \.self) { index in Text(history[index]) }. name[index] (array subscript access) is
only recognized this way — as a direct argument like Text(history[index])
— not inside a string interpolation's \( ... ) (only arithmetic is
supported there; see "Real SwiftUI subset" above).
List follows the exact same four forms as a numeric-range ForEach (plus
VStack-style static children) — List { Text("a"); Text("b") },
List(0..<n) { i in ... }, List(history, id: \.self) { entry in ... }, or
List(history.indices, id: \.self) { index in ... } — just rendered as a
scrollable, full-width list instead of a centered stack.
A let/var declaration can also hold a color — e.g. let accent = Color(red: 1.0, green: 0.376, blue: 0.2) (or Color(.systemGray5),
.orange, Color.orange) — and be used as .foregroundColor(accent) /
.background(accent).
More views and modifiers (v0.8)
Views: LazyVStack/LazyHStack (same as VStack/HStack), Group,
EmptyView, Divider, Label("Title", systemImage: "star") (or the
Label { ... } icon: { ... } form), ProgressView() (spinner),
ProgressView("Loading"), ProgressView(value: 0.4) / (value: x, total: 10)
(bar), Link("Title", destination: URL(string: "...")!) (shown as a blue link;
tapping does nothing), Color.orange / Color(red:green:blue:) used as a
view (fills available space), Form + Section("Header") { ... } (also
Section { } header: { } footer: { }), TabView with .tabItem { Label(...) }
on each tab (a working tab bar at the bottom; the selected tab survives
re-renders; the iOS 18 form Tab("Home", systemImage: "house") { ... } works too), ContentUnavailableView("No Items", systemImage: "tray", description: Text("...")) (centered empty-state), Stepper("Qty: \(n)", value: $n, in: 0...10, step: 1) and
Picker("Size", selection: $size) { Text("S").tag("S") ... } (a menu by
default, or .pickerStyle(.segmented); options can come from a ForEach).
Stepper and Picker are interactive like Toggle/Slider.
Modifiers that change the look: .shadow(color:radius:x:y:),
.border(_:width:), .offset(x:y:), .scaleEffect(_:),
.rotationEffect(.degrees(_)), .blur(radius:), .lineLimit(_:),
.textCase(.uppercase/.lowercase), .italic(), .underline(),
.strikethrough(), .fontWeight(_:), .kerning(_:)/.tracking(_:),
.lineSpacing(_:), .hidden(), .zIndex(_:), .tint(_:)/.accentColor(_:),
and .onTapGesture { count += 1 } (runs the same statements a Button's
closure can). Colors also accept Color.orange and .black.opacity(0.3).
Accepted but ignored (real SwiftUI API with no visible effect in a static
preview, so a pasted file doesn't fail because of it): .animation,
.transition, .onAppear/.onDisappear/.onChange/.task, all
.accessibility..., every ...Style selector (.buttonStyle,
.textFieldStyle, .labelStyle, ...), .ignoresSafeArea, .fixedSize,
.clipped, .toolbar, .alert/.sheet/.popover/.confirmationDialog
(their content is not shown), .keyboardType, .focused, .listRow...,
.swipeActions, .contextMenu, and about a hundred more. A misspelled
modifier gets a "Did you mean ...?" hint.
Not supported yet: Menu, DatePicker, Grid/LazyVGrid, GeometryReader,
Canvas, .overlay/.background with a view, Text + Text, asset images.
ScrollView { ... } (also ScrollView(.horizontal) { ... } and
ScrollView(showsIndicators: false) { ... } — the indicator argument is
accepted but has no visual effect) fills the available space and scrolls its
content when it overflows.
Navigation
NavigationStack { ... } — only the plain form, a bare trailing closure with
no arguments; real SwiftUI's path:/NavigationPath (fully programmatic
push/pop) isn't supported. It fills all the available space (like real
SwiftUI's own NavigationStack does), and .navigationTitle("...") — usually
chained onto the one view directly inside it — sets the bold, left-aligned
title shown in its nav bar.
NavigationStack {
VStack { ... }
.navigationTitle("Counter")
}
NavigationLink inside a NavigationStack is real, tappable push
navigation — not just parsed and ignored. Tapping a NavigationLink row in
the preview actually pushes its destination on top of the current screen,
with a small centered nav-bar title and a working "‹ Back" button that pops
back to where you were, matching real SwiftUI's own push/back UX. Two forms
are supported:
NavigationLink {
HistoryDetailView(entry: history[index], index: index, total: history.count)
} label: {
Text(history[index])
}
NavigationLink("Go to detail") {
Text("Detail screen")
}
The destination (first trailing closure, or the only closure in the second
form) can be any single view or component call, or several views (wrapped
in an implied VStack, same as body's own top-level-statements rule); a
.navigationTitle("...") chained inside the destination sets that pushed
screen's nav-bar title, the same way it does for the root NavigationStack.
NavigationLink is commonly used as a List/ForEach row — e.g. pushing a
detail screen per array element via List(history.indices, id: \.self) { index in NavigationLink { ... } label: { ... } } — see
examples/ContentView.swift-style patterns.
A quirk worth knowing: the navigation stack is purely a webview-side
convenience, not part of your program's state — it isn't a @State and
can't be read or set from your code. Any time the preview re-renders from a
fresh keystroke (not a tap), it resets back to the root screen, even if you
had pushed a detail screen. In practice this is rarely noticeable, since
typing in your Swift file while a detail screen happens to be open is an
unusual thing to do — but it's a deliberate simplification, not a bug, if
you notice a pushed screen "snap back" to the list while editing.
Reusable components
A second (and later) struct X: View { ... } in the same file, after your
main screen struct, is a small reusable View you can call like any built-in
view — a custom button, a labeled row, etc.:
struct CounterButton: View {
let label: String
let color: Color
let action: () -> Void
var body: some View {
Button(label, action: action)
.font(.largeTitle)
.frame(width: 64, height: 64)
.background(color)
.foregroundStyle(.white)
.clipShape(Circle())
}
}
used as:
CounterButton(label: "-", color: .orange) {
count -= 1
}
.disabled(count == 0)
Its stored properties (let name: Type, no default value) work like real
Swift's synthesized memberwise initializer: every property must be filled at
the call site, and — except for a closure-typed one — by a named argument
(label: "-", not a bare "-"). Supported property types are String,
Color, Bool, Int/Double/CGFloat/Float, [String], and a
closure type written () -> Void. A closure-typed property is filled by a
trailing closure at the call site instead of a named argument (exactly
like Button's own trailing closure) — only one closure-typed property is
allowed per component, since only one trailing closure can be written per
call. Inside that trailing closure you can reference your screen's own
@State (count, history, ...) exactly as you would inside a Button's
own action closure; the same
+=/-=/=/.toggle()/.append(_:)/.insert(_:at:)/.removeAll()
statements are supported there. To have the component's own body run that
closure (usually by forwarding it into an inner Button), give the
closure-typed property to Button's action: argument, as CounterButton
does above: Button(label, action: action).
@Binding: a component property written @Binding var count: Int (or
[String], Bool, a String, ...) is filled at the call site with a $
binding to your own state — CounterTab(count: $count, history: $history).
Reads see the current value, and writes made inside the component (a Button's
count += 1, a Toggle(isOn: $flag), ...) update your state, so the whole
screen re-renders. The component's property name may differ from yours
(count: $total). Constants: a stored property with a starting value
(let accent = Color(red: 1.0, green: 0.4, blue: 0.2)) is a constant local to
the component, not an argument.
Ternary: condition ? a : b works inside a call's argument — e.g.
.opacity(count == 0 ? 0.5 : 1.0) or .font(.system(size: big ? 40 : 20)) —
using the same condition grammar as if (comparisons, !, &&, ||).
A component's own body only sees its own properties — not your screen's
@State — matching real Swift's lexical scoping; modifiers chained onto a
call to it (like .disabled(count == 0) above) are evaluated against the
caller's state, so they can still see the screen's @State normally.
What's not covered here (would need real effort to add): full string
interpolation (only a bare @State/var/let name, simple numeric
arithmetic over numbers/names, or an array's .count/.isEmpty like
\(history.count) is supported inside \(...) — not function calls, other
member access, or string concatenation), array
element types other than String (e.g. [Int] or a custom struct),
()-grouping in if conditions, fully programmatic navigation
(NavigationPath/path:, or popping more than one screen at once —
NavigationLink's own tap-to-push-and-back works, see "Navigation" above),
Color(red:green:blue:) custom colors, asset-catalog
images, .overlay/.background with a trailing-closure view, real SF
Symbol rendering, any Button action beyond
+=/-=/=/.toggle()/.append(_:)/.insert(_:at:)/.removeAll() (no
if, no function calls, no multi-@State-variable expressions), and a
reusable component with more than one closure-typed property or with
positional (unlabeled) arguments.
Frame subset (original, invented API — not real Swift)
import TeachKit // any `import X` line is accepted and ignored
struct StudentScreen: TKScreen {
init() {} // optional, must be empty
func build() -> UIView {
let root = UIView(frame: TKRect(x: 0, y: 0, width: 390, height: 844))
root.backgroundColor = .white
let label = UILabel()
label.frame = TKRect(x: 24, y: 100, width: 342, height: 40)
label.text = "Hello"
label.fontSize = 28
label.textColor = .systemBlue
root.addSubview(label)
let button = UIButton(title: "Tap me")
button.frame = TKRect(x: 24, y: 160, width: 200, height: 44)
button.backgroundColor = .systemBlue
button.titleColor = .white
button.cornerRadius = 8
root.addSubview(button)
return root
}
}
- The struct name can be anything, but it's always the file's text that's
interpreted — there's no copy-into-a-build-folder step like older
approaches needed, so there's no requirement that the type be named a
particular way, and no dependency on any workspace folder being open.
- View types:
UIView, UILabel, UIButton, UIStackView (stack views
render as a plain container — their children still use explicit frames;
there's no automatic linear layout yet, see below).
- Properties:
frame (via TKRect(x:y:width:height:), any subset of
arguments, defaulting to 0), backgroundColor, cornerRadius, text /
title, textColor / titleColor, fontSize, axis (.horizontal /
.vertical), spacing.
- Colors:
.white, .black, .clear, .systemBlue, .systemGray,
.systemRed, or TKColor(hex: "#RRGGBB").
parent.addSubview(child) to build the hierarchy.
let name = ... to bind a view to a name, then refer to it later.
What's NOT supported in the frame subset (be upfront with students)
- Real Auto Layout / constraints — frames only, no constraint solver, and
UIStackView doesn't auto-arrange its children (yet — see below).
- View controller lifecycle, storyboards, segues, gestures, animations,
table/collection views, navigation.
- Arbitrary real Swift: no
if/for/closures/generics/computed properties/
custom types. The parser recognizes exactly the shape shown above; code
outside it produces a specific "here's what I don't understand" error
rather than a generic crash, but it won't get smarter than that on its
own.
- Anything requiring a real Simulator (camera, sensors, real touch).
Treat this as training wheels for the concepts (views, hierarchy,
properties) before students touch a real Mac for Auto Layout, Storyboards,
and lifecycle — not a substitute for it.
Setup (Windows)
- Open the
extension/ folder in VS Code.
- Install and compile:
cd extension
npm install
npm run compile
- Press F5 to launch an Extension Development Host with the extension
loaded.
- In that new window, open either example, run
"iOS Live Preview: Open Preview" (Ctrl+Shift+M), and start typing —
no save needed:
examples/ContentView.swift — real SwiftUI subset
examples/CounterApp.swift — real SwiftUI subset, with a working
@State counter and a [String] history log — try tapping the buttons
in the preview
examples/InteractiveDemo.swift — a Toggle, TextField, Slider, an
if/else driven by state, and a ForEach-generated list — try
interacting with all of them in the preview
examples/HelloScreen.swift — original frame subset
No Swift toolchain install is required for this extension itself (unlike an
earlier version of this project) — there's no Swift involved at any point.
Known rough edges / next steps, in priority order
- No Swift logic engine yet (v0.10).
@State values can be numbers,
strings, Bools, Dates, Colors and arrays of those, and Button closures
support assignments, +=-style math, .toggle(), .append/.insert/
.removeAll/.remove(at:) and dismiss(). Custom struct models,
enums, your own funcs called from actions, ObservableObject /
@Observable, and general closures are the next big piece.
UIStackView (frame subset) doesn't actually lay out its children.
It's currently just a labeled container; children still need explicit
frames. The SwiftUI subset's VStack/HStack already do real layout via
CSS flexbox.
- No error recovery within a single parse. The parser stops at the
first syntax error rather than collecting several.
- Single-panel only. Only one preview panel at a time; switching active
editors re-targets it (intentional).
- Approximations. Layout is CSS, not SwiftUI's exact algorithm, so spacing
and wrapping can differ by a few points; animations and gestures other than
taps aren't simulated; maps, video and charts are placeholders.