Skip to content
| Marketplace
Sign in
Visual Studio Code>Education>iOS Live Preview (Xcode-style, for Windows)New to Visual Studio Code? Get it now.
iOS Live Preview (Xcode-style, for Windows)

iOS Live Preview (Xcode-style, for Windows)

patrick corral

|
1 install
| (0) | Free
Live, as-you-type preview of real SwiftUI code in a phone-shaped canvas — no Swift toolchain or Xcode required. Views, modifiers, gradients, sheets, alerts, grids, SF Symbols and more.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

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:

  1. 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.
  2. 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

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)

  1. Open the extension/ folder in VS Code.
  2. Install and compile:
    cd extension
    npm install
    npm run compile
    
  3. Press F5 to launch an Extension Development Host with the extension loaded.
  4. 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

  1. 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.
  2. 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.
  3. No error recovery within a single parse. The parser stops at the first syntax error rather than collecting several.
  4. Single-panel only. Only one preview panel at a time; switching active editors re-targets it (intentional).
  5. 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.
  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft