What's new in SwiftUI in iOS 27

Issue #1048

WWDC26 covers iOS 27, macOS 27, watchOS 27, tvOS 27, and visionOS 27. It brings new capabilities you can adopt and a couple of behavioral shifts that can break code that compiled fine before. This article starts with the two changes that can surprise you, then moves to the new APIs worth reaching for.

@State is now a macro

The biggest source level change is that @State is no longer a property wrapper. It is a macro. Most views keep working untouched, but one pattern now fails to compile. If you give a @State property an initial value at its declaration and then assign it again inside init, before the other stored properties are set, the compiler stops you:

struct ContentView: View {
    var name: String
    @State private var counter: Int = 0

    init(name: String) {
        self.counter = 42
        self.name = name
    }

    var body: some View { Text("\(name): \(counter)") }
}

This produces Variable 'self.name' used before being initialized, because the macro synthesizes real backing storage, so assigning to a @State property before the rest of self is set counts as premature use of self.

The instinct is to reorder the assignments so name comes first. That is the wrong fix. Assigning a new value to a @State property that already has an initial value never did what you expect, and the body would read 0 rather than 42. The correct fix is to drop the initial value at the declaration and set it only in the init, so the value is initialized once in one place.

Two related cases can appear. Composing another property wrapper on top of @State can trigger invalid redeclaration of synthesized property when both try to create backing storage with the same name, which you fix by restructuring so the names do not collide. And views that relied on Swift’s synthesized private memberwise init from an extension will find it missing, so define that initializer explicitly when you need it.

Result builders unified under ContentBuilder

SwiftUI’s result builders, most visibly @ViewBuilder, now sit under a unified @ContentBuilder. Builders no longer constrain their contents to conform to View, which changes how some expressions type check. The case you are most likely to meet is passing a styled ShapeStyle straight into overlay or background:

Text("Hello")
    .overlay(Color.blue.opacity(0.70).blendMode(.overlay))

This now reports ambiguous use of 'opacity'. Switch to the trailing closure form, which selects the builder based overload and breaks the tie:

Text("Hello")
    .overlay { Color.blue.opacity(0.70).blendMode(.overlay) }

A related case shows up when another module declares a type that shares a name with a SwiftUI type, such as its own Color. The old View constraint used to rule out the non conforming candidate, so qualify it with SwiftUI.Color to disambiguate. There is also a Swift Charts type check slowdown for deeply branching chart content, but only when your deployment target is below 27, which you resolve by extracting the branches into a function marked @ChartContentBuilder.

Drag to reorder in any container

Until now, drag to reorder meant List and onMove, or a hand rolled gesture. The 2027 SDK lets you reorder items in any container, including LazyVStack, LazyVGrid, stacks, and custom layouts. You put .reorderable() on the ForEach and .reorderContainer(for:) on the container:

struct StickerGrid: View {
    @State private var stickers: [Sticker] = []

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns) {
                ForEach(stickers) { sticker in
                    StickerView(sticker)
                }
                .reorderable()
            }
            .reorderContainer(for: Sticker.self) { difference in
                difference.apply(to: &stickers)
            }
        }
    }
}

When a drag ends, SwiftUI hands your closure a ReorderDifference that describes what moved and where it landed, carrying the sources being moved and a destination whose position is either .before(id) or .end. You apply that to your own data. The for: overload keys on Identifiable, so your model needs an id. For sectioned content, tag each ForEach with .reorderable(collectionID:) and use reorderContainer(for:in:) so the difference tells you which section the items moved to.

The reorder container already acts as a drag source and a drop target, so basic reordering needs nothing more. You can add your own dragContainer(for:) to customize the payload, or a per child dropDestination(for:isEnabled:) to combine items by dropping one onto another. This works on iOS, macOS, watchOS, and visionOS 27, and is unavailable on tvOS.

Swipe actions outside of List

Swipe actions had the same limitation. The swipeActions(edge:allowsFullSwipe:content:) modifier only did anything inside a List. Now it works in any scrollable container once you mark that container with the new swipeActionsContainer() modifier:

ScrollView {
    LazyVStack {
        ForEach(stickers) { sticker in
            StickerRow(sticker)
                .swipeActions {
                    Button(role: .destructive) {
                        stickers.removeAll { $0.id == sticker.id }
                    } label: {
                        Label("Delete", systemImage: "trash")
                    }
                }
        }
    }
}
.swipeActionsContainer()

The row modifier itself is unchanged, with edge defaulting to .trailing and allowsFullSwipe to true. Without swipeActionsContainer() on the container, the row level modifier still has no effect outside a List. There is also a new overload with an onPresentationChanged closure that tells you when a row’s actions are revealed or hidden. This is available on iOS, macOS, watchOS, and visionOS 27, and remains unavailable on tvOS.

AsyncImage finally caches

AsyncImage now applies standard HTTP caching by default. An image that already loaded can be served from the cache instead of fetched again, honoring the server’s cache headers, with no code change and no API to turn on. This is runtime behavior in the framework’s image loader, so an app gets it when running on a 2027 OS even if it was built against an older SDK. The plain AsyncImage(url:) you already have gains the cache for free.

When you want control, a new AsyncImage(request:) initializer takes a URLRequest instead of a URL, so you can set the cache policy per image:

AsyncImage(request: URLRequest(url: imageURL, cachePolicy: .returnCacheDataElseLoad)) { image in
    image.resizable().scaledToFit()
} placeholder: {
    ProgressView()
}

The asyncImageURLSession(_:) modifier sets the URLSession for every AsyncImage in a subtree, so you can configure your own URLCache with the memory and disk capacity you want. Both require the 27 SDK to call.

Per item dialogs and alerts

confirmationDialog and alert gain overloads that take an item: Binding<T?> in place of an isPresented: Binding<Bool>, the same shape as sheet(item:). The dialog presents while the binding holds a value, the unwrapped value flows into the actions and message closures, and SwiftUI resets the binding to nil on dismissal:

struct PhotoGrid: View {
    @State private var photoToDelete: Photo?

    var body: some View {
        PhotoList(deleteAction: { photoToDelete = $0 })
            .confirmationDialog("Delete photo?", item: $photoToDelete) { photo in
                Button("Delete \(photo.name)", role: .destructive) {
                    delete(photo)
                }
            } message: { photo in
                Text("\(photo.name) will be removed from all of your devices.")
            }
    }
}

This removes familiar boilerplate. When a dialog acts on a specific value, like the row a person tapped, you no longer need a separate Bool plus a stored optional or a presenting: argument. One optional drives both presentation and the value, with no Identifiable requirement.

Toolbars in constrained space

When a toolbar has more items than fit, the system moves the overflow into a trailing menu, and the new APIs give you control over what stays and what goes. visibilityPriority(_:) sets how readily an item overflows, with .high items staying in the bar and .low items leaving first:

.toolbar {
    ToolbarItemGroup {
        UndoButton()
        RedoButton()
    }
    .visibilityPriority(.high)
}

ToolbarOverflowMenu holds content that always lives in the overflow menu, and a ToolbarItem placed with .topBarPinnedTrailing never leaves the bar. You can minimize the bar on scroll with toolbarMinimizeBehavior(_:for:), drop the default margins around an item with contentMarginsRemoved(), and control the status bar through the new ToolbarPlacement.statusBar, which on iOS replaces the deprecated statusBarHidden(_:). Toolbar builders also accept ForEach and EmptyView now, so you can generate items from a collection the same way you build a view body. Availability varies per API, so check before adopting on a given platform.

Document based apps get a new foundation

For document apps on iOS, macOS, and visionOS 27, two new protocols replace FileDocument and ReferenceFileDocument for new code. ReadableDocument handles reading, and WritableDocument adds saving. The document is a reference type marked @Observable, so SwiftUI does not recreate it on every change, and a TextEditor bound to a document property does not lose its model on each keystroke.

The model gets direct access to the file URL, and reading and writing run in the background automatically. You convert between your data and disk through a snapshot, using either the FileWrapperDocumentReader and FileWrapperDocumentWriter convenience types or a fully custom reader and writer for streaming or direct URL access. Packages can be read and written incrementally, and progress flows through Subprogress. One thing to remember is that SwiftUI tracks unsaved changes through undo actions, so without registered undo actions it will not autosave. If you are starting a document app today and your deployment target is 27 or later, reach for these rather than the older protocols.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation