# Deprecated API Migration

A map from older SwiftUI and iOS patterns (iOS 15 era through iOS 26) to their
current replacements. Each entry shows the old form, the new form, and notes.
Code is written for iOS 26 and Swift 6.3. Every entry names the release its
replacement needs, so check that against the project's deployment target
before migrating; nothing here assumes a single baseline.

Several old APIs here (`NavigationView`, `foregroundColor`, `cornerRadius`,
`ActionSheet`, the `Alert` struct, `presentationMode`, `tabItem`, the
`.navigationBar` toolbar placements) carry `deprecated: 100000.0` in the SDK.
Apple's documentation lists them as deprecated, but the compiler does not warn,
so a clean build does not mean they are gone; search for them.

## Contents

1. [NavigationView to NavigationStack](#1-navigationview-to-navigationstack)
2. [NavigationView Sidebar to NavigationSplitView](#2-navigationview-sidebar-to-navigationsplitview)
3. [ObservableObject and @StateObject to @Observable and @State](#3-observableobject-and-stateobject-to-observable-and-state)
4. [@ObservedObject to let or @Bindable](#4-observedobject-to-let-or-bindable)
5. [@EnvironmentObject to @Environment](#5-environmentobject-to-environment)
6. [foregroundColor to foregroundStyle](#6-foregroundcolor-to-foregroundstyle)
7. [Single-Value onChange to Two-Value onChange](#7-single-value-onchange-to-two-value-onchange)
8. [ActionSheet to confirmationDialog](#8-actionsheet-to-confirmationdialog)
9. [Legacy Alert to alert with Actions](#9-legacy-alert-to-alert-with-actions)
10. [AnyView to @ViewBuilder and Concrete Types](#10-anyview-to-viewbuilder-and-concrete-types)
11. [onAppear with Task to .task](#11-onappear-with-task-to-task)
12. [presentationMode to dismiss](#12-presentationmode-to-dismiss)
13. [GeometryReader Overuse to containerRelativeFrame and Layout](#13-geometryreader-overuse-to-containerrelativeframe-and-layout)
14. [PreviewProvider to the #Preview Macro](#14-previewprovider-to-the-preview-macro)
15. [XCTest to Swift Testing](#15-xctest-to-swift-testing)
16. [onDelete and EditButton to swipeActions](#16-ondelete-and-editbutton-to-swipeactions)
17. [UIApplication.shared.open to openURL](#17-uiapplicationsharedopen-to-openurl)
18. [@FetchRequest to @Query](#18-fetchrequest-to-query)
19. [Type-Erased Returns to some and any](#19-type-erased-returns-to-some-and-any)
20. [sheet(isPresented:) with an Optional to sheet(item:)](#20-sheetispresented-with-an-optional-to-sheetitem)
21. [UIColor Interop to Asset Colors and Color.resolve](#21-uicolor-interop-to-asset-colors-and-colorresolve)
22. [ForEach over a Range to Identifiable Data](#22-foreach-over-a-range-to-identifiable-data)
23. [Toolbar Placements navigationBar to topBar](#23-toolbar-placements-navigationbar-to-topbar)
24. [cornerRadius to clipShape](#24-cornerradius-to-clipshape)
25. [tabItem to Tab (iOS 18)](#25-tabitem-to-tab-ios-18)
26. [showsIndicators to scrollIndicators](#26-showsindicators-to-scrollindicators)

## 1. NavigationView to NavigationStack

`NavigationView` has been superseded since iOS 16 and is marked for deprecation
(no compiler warning). Use `NavigationStack` (iOS 16+)
for one column of pushed screens and `NavigationSplitView` for several columns.

Before:

```swift
NavigationView {
    List(courses) { course in
        NavigationLink(destination: CourseDetail(course: course)) {
            Text(course.title)
        }
    }
}
.navigationViewStyle(.stack)
```

After:

```swift
@State private var path: [Course] = []

NavigationStack(path: $path) {
    List(courses) { course in
        NavigationLink(course.title, value: course)
    }
    .navigationDestination(for: Course.self) { CourseDetail(course: $0) }
}
```

- The path binding gives code full control over what is pushed.
- A value-based `NavigationLink` separates the trigger from the destination, so
  rows stay cheap.
- `.navigationViewStyle(.stack)` is no longer needed.

## 2. NavigationView Sidebar to NavigationSplitView

Before:

```swift
NavigationView {
    TeamSidebar()
    Text("Choose a team")
}
.navigationViewStyle(.columns)
```

After (iOS 16+):

```swift
@State private var team: Team?
@State private var player: Player?

NavigationSplitView {
    List(teams, selection: $team) { NavigationLink($0.name, value: $0) }
} content: {
    if let team {
        List(team.players, selection: $player) { NavigationLink($0.name, value: $0) }
    } else {
        ContentUnavailableView("No team selected", systemImage: "sidebar.left")
    }
} detail: {
    if let player {
        PlayerCard(player: player)
    } else {
        ContentUnavailableView("No player selected", systemImage: "doc.text")
    }
}
```

- The split view states its two- or three-column layout explicitly.
- Column visibility is a `NavigationSplitViewVisibility` value passed through
  the `columnVisibility:` binding.
- In compact size classes it collapses into a stack on its own.

## 3. ObservableObject and @StateObject to @Observable and @State

The Observation framework (iOS 17+) replaces observation built on Combine. An
`@Observable` class records which properties each view reads.

Before:

```swift
final class ReaderSettings: ObservableObject {
    @Published var fontSize = 16.0
    @Published var isNightMode = false
    @Published var typeface = "Serif"

    func restoreDefaults() { fontSize = 16; isNightMode = false; typeface = "Serif" }
}

struct ReaderSettingsView: View {
    @StateObject private var settings = ReaderSettings()

    var body: some View {
        Form {
            Slider(value: $settings.fontSize, in: 12...32)
            Toggle("Night mode", isOn: $settings.isNightMode)
            Picker("Typeface", selection: $settings.typeface) {
                Text("Serif").tag("Serif")
                Text("Sans").tag("Sans")
            }
        }
    }
}
```

After:

```swift
@MainActor @Observable
final class ReaderSettings {
    var fontSize = 16.0
    var isNightMode = false
    var typeface = "Serif"

    func restoreDefaults() { fontSize = 16; isNightMode = false; typeface = "Serif" }
}

struct ReaderSettingsView: View {
    @State private var settings = ReaderSettings()

    var body: some View {
        Form {
            Slider(value: $settings.fontSize, in: 12...32)
            Toggle("Night mode", isOn: $settings.isNightMode)
            Picker("Typeface", selection: $settings.typeface) {
                Text("Serif").tag("Serif")
                Text("Sans").tag("Sans")
            }
        }
    }
}
```

Steps:

1. Replace the `ObservableObject` conformance with the `@Observable` macro.
2. Delete every `@Published`; tracking is automatic.
3. Change owned instances from `@StateObject` to `@State`. The body stays as it was:
   `$settings.fontSize` and the other bindings still work.

Notes:

- Computed properties built on stored ones are tracked without extra work.
- A view re-evaluates only when a property it actually read changes, at no
  extra cost.
- Needs an iOS 17 deployment target.
- `ObservableObject` is superseded, not formally deprecated, and the compiler
  does not warn about it. On projects that target iOS 16 or earlier, leave
  working `ObservableObject` code alone.

## 4. @ObservedObject to let or @Bindable

Before:

```swift
struct MemberCard: View {
    @ObservedObject var member: Member

    var body: some View {
        TextField("Nickname", text: $member.nickname)
        Toggle("Visible", isOn: $member.isVisible)
    }
}
```

After, read only:

```swift
struct MemberBadge: View {
    let member: Member
    var body: some View { Text(member.nickname) }
}
```

After, with bindings:

```swift
struct MemberCard: View {
    @Bindable var member: Member

    var body: some View {
        TextField("Nickname", text: $member.nickname)
        Toggle("Visible", isOn: $member.isVisible)
    }
}
```

With `@Observable` types, `@ObservedObject` has no role. A plain `let` already
updates the view for every property it reads; `@Bindable` is only for `$`
bindings.

## 5. @EnvironmentObject to @Environment

Before:

```swift
RootView().environmentObject(session)

struct GateView: View {
    @EnvironmentObject var session: Session
    var body: some View { session.isSignedIn ? AnyView(HomeView()) : AnyView(SignInView()) }
}
```

After (iOS 17+):

```swift
RootView().environment(session)

struct GateView: View {
    @Environment(Session.self) private var session

    var body: some View {
        if session.isSignedIn { HomeView() } else { SignInView() }
    }
}
```

Bindings from an environment object come from a local `@Bindable` copy:

```swift
var body: some View {
    @Bindable var session = session
    Toggle("Stay signed in", isOn: $session.staySignedIn)
}
```

## 6. foregroundColor to foregroundStyle

`foregroundColor(_:)` is marked for deprecation and renamed to
`foregroundStyle(_:)`; the compiler does not warn yet. `foregroundStyle(_:)`
(iOS 15+) takes any `ShapeStyle`, so gradients, hierarchical styles and
materials work directly.

```swift
Text("Overdue").foregroundStyle(.red)
Text("Updated 2 min ago").foregroundStyle(.secondary)
Text("Premium")
    .foregroundStyle(.linearGradient(colors: [.orange, .pink],
                                     startPoint: .leading, endPoint: .trailing))
```

- Passing a `Color` is a drop-in replacement.
- Also accepts `.tint`, `.selection` and the hierarchical `.primary`,
  `.secondary`, `.tertiary`, `.quaternary`. A gradient was never possible with
  `foregroundColor`.
- `foregroundStyle(_:_:)` and `foregroundStyle(_:_:_:)` set two or three
  hierarchy levels for child content in one call.
- `NSAttributedString.Key.foregroundColor` is unrelated: it is the Foundation
  and UIKit attribute key used with `NSAttributedString`, Core Text and PDF
  rendering. It is not deprecated and has no SwiftUI counterpart.

## 7. Single-Value onChange to Two-Value onChange

The `onChange(of:perform:)` form whose closure takes one value is deprecated in
iOS 17.

```swift
// Before
.onChange(of: volume) { newVolume in player.setVolume(newVolume) }

// After (iOS 17+)
.onChange(of: volume) { oldVolume, newVolume in
    if newVolume > oldVolume { showBoostHint = true }
    player.setVolume(newVolume)
}
.onChange(of: volume) { _, newVolume in player.setVolume(newVolume) }
.onChange(of: volume, initial: true) { _, newVolume in player.setVolume(newVolume) }
```

- The closure receives the old and new values; write `_` for an old value you
  do not use.
- Comparing old and new inline removes the need for a helper state property.
- `initial: true` also runs the closure when the view first appears.

## 8. ActionSheet to confirmationDialog

Before:

```swift
.actionSheet(isPresented: $isAsking) {
    ActionSheet(title: Text("Photo"), message: Text("Choose an action"), buttons: [
        .default(Text("Save")) { save() },
        .destructive(Text("Delete")) { delete() },
        .cancel()
    ])
}
```

After (iOS 15+):

```swift
.confirmationDialog("Photo", isPresented: $isAsking, titleVisibility: .visible) {
    Button("Save", action: save)
    Button("Delete", role: .destructive, action: delete)
    Button("Cancel", role: .cancel) { }
} message: {
    Text("Choose an action")
}
```

- Regular `Button` views with roles replace the `ActionSheet.Button` array.
- `titleVisibility` controls the title; on iOS it is hidden by default.
- A cancel button is added for you when you leave it out.

## 9. Legacy Alert to alert with Actions

Before:

```swift
.alert(isPresented: $confirmLeave) {
    Alert(title: Text("Leave group?"),
          message: Text("You can rejoin later."),
          primaryButton: .destructive(Text("Leave")) { leaveGroup() },
          secondaryButton: .cancel())
}
```

After (iOS 15+):

```swift
.alert("Leave group?", isPresented: $confirmLeave) {
    Button("Leave", role: .destructive, action: leaveGroup)
    Button("Cancel", role: .cancel) { }
} message: {
    Text("You can rejoin later.")
}
```

Passing the data being confirmed:

```swift
.alert("Remove device?", isPresented: $confirmRemoval, presenting: pendingDevice) { device in
    Button("Remove \(device.name)", role: .destructive) { remove(device) }
} message: { device in
    Text("\(device.name) will be signed out.")
}
```

With `presenting:`, both closures receive the item, so no separate optional
state has to be tracked.

## 10. AnyView to @ViewBuilder and Concrete Types

Before:

```swift
func screen(for step: CheckoutStep) -> AnyView {
    switch step {
    case .cart: AnyView(CartView())
    case .address: AnyView(AddressView())
    case .payment: AnyView(PaymentView())
    }
}
```

After:

```swift
@ViewBuilder
func screen(for step: CheckoutStep) -> some View {
    switch step {
    case .cart: CartView()
    case .address: AddressView()
    case .payment: PaymentView()
    }
}
```

- `AnyView` hides the concrete type, which blocks efficient diffing and
  transitions. `@ViewBuilder` keeps the types, so SwiftUI can preserve identity
  and animate between branches.
- Keep `AnyView` for APIs that really must store views of different types in
  one collection.
- On iOS 26 `AnyView` still works and is still a performance anti-pattern.

## 11. onAppear with Task to .task

```swift
// Before
.onAppear {
    Task { receipts = try await ledger.receipts() }
}

// After
.task {
    do { receipts = try await ledger.receipts() }
    catch { loadError = error }
}

// Re-run when a dependency changes
.task(id: selectedMonth) {
    receipts = (try? await ledger.receipts(in: selectedMonth)) ?? []
}
```

`.task` cancels its work when the view disappears, which avoids retain cycles
and updates to views that are gone. `.task(id:)` restarts when the id changes.

## 12. presentationMode to dismiss

```swift
// Before
@Environment(\.presentationMode) var presentationMode
Button("Close") { presentationMode.wrappedValue.dismiss() }

// After (iOS 15+)
@Environment(\.dismiss) private var dismiss
Button("Close") { dismiss() }
```

- `dismiss` is a `DismissAction` that you call directly; there is no
  `wrappedValue`.
- It closes sheets, full-screen covers and pushed navigation destinations.

## 13. GeometryReader Overuse to containerRelativeFrame and Layout

`GeometryReader` has a performance cost and makes layout harder to compose.
The `Layout` protocol arrived in iOS 16 and `containerRelativeFrame` in iOS 17
for sizing relative to a container.

Before:

```swift
GeometryReader { proxy in
    HStack(spacing: 0) {
        MapPanel().frame(width: proxy.size.width * 0.65)
        DetailsPanel().frame(width: proxy.size.width * 0.35)
    }
}
```

After, iOS 17+:

```swift
HStack(spacing: 0) {
    MapPanel().containerRelativeFrame(.horizontal) { width, _ in width * 0.65 }
    DetailsPanel().containerRelativeFrame(.horizontal) { width, _ in width * 0.35 }
}
```

After, iOS 16+ with a custom layout:

```swift
struct WeightedRow: Layout {
    var weights: [CGFloat]

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        proposal.replacingUnspecifiedDimensions()
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
                       subviews: Subviews, cache: inout ()) {
        guard weights.count == subviews.count else { return }
        let total = weights.reduce(0, +)
        var x = bounds.minX
        for (subview, weight) in zip(subviews, weights) {
            let width = bounds.width * weight / total
            subview.place(at: CGPoint(x: x, y: bounds.minY),
                          proposal: ProposedViewSize(width: width, height: bounds.height))
            x += width
        }
    }
}
```

- `GeometryReader` is still right when you truly must read the proposed size
  and cannot state the layout declaratively.
- Proportional sizing: `containerRelativeFrame`. Custom arrangements: the
  `Layout` protocol. Both avoid the bottom-up sizing of `GeometryReader` that
  makes composition difficult.

## 14. PreviewProvider to the #Preview Macro

Before:

```swift
struct BoardingPassView_Previews: PreviewProvider {
    static var previews: some View {
        BoardingPassView(pass: .sample)
            .previewDevice("iPhone 16 Pro")
            .preferredColorScheme(.dark)
    }
}
```

After:

```swift
#Preview("Light") {
    BoardingPassView(pass: .sample)
}

#Preview("Dark") {
    BoardingPassView(pass: .sample)
        .preferredColorScheme(.dark)
}
```

Widgets and UIKit:

```swift
#Preview("Small", as: .systemSmall) {
    StepsWidget()
} timeline: {
    StepsEntry(date: .now, steps: 4_200)
}

#Preview {
    let controller = ScannerViewController()
    controller.title = "Scanner"
    return controller
}
```

- `#Preview` needs Xcode 15 and less boilerplate, and names each preview
  directly. SwiftUI view previews work with any deployment target; the UIKit
  and WidgetKit forms need iOS 17.
- Migration: delete the whole `PreviewProvider` struct and write `#Preview`
  blocks in its place.

## 15. XCTest to Swift Testing

Swift Testing needs Xcode 16 and runs alongside XCTest.

Before:

```swift
final class FareCalculatorTests: XCTestCase {
    var calculator: FareCalculator!

    override func setUp() { calculator = FareCalculator(currency: "EUR") }
    override func tearDown() { calculator = nil }

    func testBaseFare() {
        XCTAssertEqual(calculator.fare(km: 10), 12.5, accuracy: 0.001)
    }

    func testFaresArePositive() {
        for km in [1, 5, 50] {
            XCTAssertTrue(calculator.fare(km: km) > 0)
        }
    }
}
```

After:

```swift
import Testing

@Suite("Fare calculator")
struct FareCalculatorTests {
    let calculator = FareCalculator(currency: "EUR")

    @Test("Base fare for ten kilometres")
    func baseFare() {
        #expect(abs(calculator.fare(km: 10) - 12.5) < 0.001)
    }

    @Test("Fares are positive", arguments: [1, 5, 50])
    func positive(km: Int) {
        #expect(calculator.fare(km: km) > 0)
    }
}
```

Steps:

1. Replace the `XCTestCase` subclass with a struct marked `@Suite`.
2. Move `setUp` work into `init` or into stored property defaults. For
   teardown, make the suite a `final class` or an `actor` and use `deinit`; a
   plain struct cannot have one.
3. Replace `XCTAssert*` calls with `#expect(...)`, and use `#require(...)` where
   the test cannot continue without a value.
4. Replace loops with `@Test("description", arguments:)`.

Notes:

- XCTest and Swift Testing targets can live in the same project during the
  move.
- `@Test(.disabled("reason"))` takes the place of skipping with `XCTSkip`;
  `.enabled(if:)` covers conditional skips.

## 16. onDelete and EditButton to swipeActions

Before:

```swift
NavigationView {
    List {
        ForEach(alarms) { AlarmRow(alarm: $0) }
            .onDelete { alarms.remove(atOffsets: $0) }
            .onMove { alarms.move(fromOffsets: $0, toOffset: $1) }
    }
    .toolbar { EditButton() }
}
```

After:

```swift
NavigationStack {
    List {
        ForEach(alarms) { alarm in
            AlarmRow(alarm: alarm)
                .swipeActions(edge: .trailing, allowsFullSwipe: true) {
                    Button("Delete", role: .destructive) { remove(alarm) }
                }
                .swipeActions(edge: .leading) {
                    Button("Snooze") { snooze(alarm) }.tint(.orange)
                }
        }
        .onMove { alarms.move(fromOffsets: $0, toOffset: $1) }
    }
    .toolbar { EditButton() }
}
```

- `.swipeActions` (iOS 15+) supports several actions per row, each with its own
  tint and role.
- `EditButton` and `.onMove` are still fine for reordering. The main change is
  turning `.onDelete` into a destructive swipe action.

## 17. UIApplication.shared.open to openURL

```swift
// Before
Button("Terms") { UIApplication.shared.open(termsURL) }

// After
@Environment(\.openURL) private var openURL
Button("Terms") { openURL(termsURL) }

// Handling failure
Button("Terms") {
    openURL(termsURL) { opened in
        if !opened { showLinkError = true }
    }
}
```

- `openURL` works on every Apple platform, and it can be replaced in the
  environment to intercept or test link handling.
- Keep `UIApplication.shared` out of SwiftUI views.

## 18. @FetchRequest to @Query

Once the models move from Core Data to SwiftData, the `@Query` macro (iOS 17+)
supersedes `@FetchRequest`.

Before:

```swift
@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \CDExpense.date, ascending: false)],
    predicate: NSPredicate(format: "amount > %f", 100.0)
)
private var expenses: FetchedResults<CDExpense>
```

After:

```swift
@Query(filter: #Predicate<Expense> { $0.amount > 100 },
       sort: \.date, order: .reverse)
private var expenses: [Expense]
```

- `#Predicate` is checked by the compiler, unlike a format string, and sorting
  takes key paths directly.
- An ancestor view supplies the container with `.modelContainer(for:)`.
- SwiftData models are ordinary Swift classes marked `@Model`, not
  `NSManagedObject` subclasses.

## 19. Type-Erased Returns to some and any

Before:

```swift
func makeHeader() -> AnyView { AnyView(Text("Inbox")) }

protocol MessageSource {
    func messages() -> AnyPublisher<[Message], Error>
}
```

After:

```swift
func makeHeader() -> some View { Text("Inbox") }

protocol MessageSource: Sendable {
    func messages() async throws -> [Message]
}

let source: any MessageSource = RemoteMessageSource()
```

- `some` marks an opaque result whose concrete type is fixed. `any` marks an
  existential that can hold different conforming types.
- In new code, prefer `async throws` to Combine publishers.
- Since Swift 5.7, `some` works in parameter position:
  `func present(_ content: some View)`.

## 20. sheet(isPresented:) with an Optional to sheet(item:)

Before, two pieces of state that can disagree:

```swift
@State private var editingNote: Note?
@State private var isEditing = false

.sheet(isPresented: $isEditing) {
    if let editingNote { NoteEditor(note: editingNote) }
}
```

After:

```swift
@State private var editingNote: Note?

.sheet(item: $editingNote) { note in
    NoteEditor(note: note)
}
```

- `.sheet(item:)` removes the chance of the flag and the value falling out of
  step.
- The sheet appears when the binding becomes non-nil and closes when it
  returns to nil; the closure receives the unwrapped value.

## 21. UIColor Interop to Asset Colors and Color.resolve

Building a `Color` from components, directly or through
`Color(uiColor: UIColor(red:green:blue:alpha:))`, is still fine. Better
options exist for the two common needs:

- Fixed brand colors: an asset catalog color such as `Color("Ocean")` is always
  the first choice; it carries light, dark and high-contrast variants.
- Reading actual components at runtime: `Color.resolve(in:)` (iOS 17+) returns
  a `Color.Resolved` with `red`, `green`, `blue` and `opacity` for the current
  environment. That covers most of the reasons code used to drop into
  `UIColor`.

```swift
struct ContrastBadge: View {
    @Environment(\.self) private var environment
    let tint: Color

    var body: some View {
        let resolved = tint.resolve(in: environment)
        let lightBackground = (resolved.red + resolved.green + resolved.blue) / 3 > 0.6
        Text("New")
            .padding(6)
            .background(tint)
            .foregroundStyle(lightBackground ? .black : .white)
    }
}
```

Use resolved colors for runtime manipulation and asset catalogs for static
brand colors.

## 22. ForEach over a Range to Identifiable Data

Before:

```swift
ForEach(0..<songs.count) { index in
    Text(songs[index].title)
}
```

After:

```swift
ForEach(songs) { song in
    Text(song.title)
}

ForEach(Array(songs.enumerated()), id: \.element.id) { position, song in
    Text("\(position + 1). \(song.title)")
}

ForEach($songs) { $song in
    TextField("Title", text: $song.title)
}
```

- `ForEach(0..<n)` is safe only when the range is constant. Data that can
  change needs an identifiable collection. The Before code draws a compiler
  warning: "non-constant range: argument must be an integer literal".
- `ForEach($items)` hands out a binding per element without index arithmetic.

## 23. Toolbar Placements navigationBar to topBar

```swift
// Before
ToolbarItem(placement: .navigationBarLeading) { closeButton }
ToolbarItem(placement: .navigationBarTrailing) { saveButton }

// After
ToolbarItem(placement: .topBarLeading) { closeButton }
ToolbarItem(placement: .topBarTrailing) { saveButton }
ToolbarItem(placement: .bottomBar) { statusText }
```

- `.topBarLeading` and `.topBarTrailing` arrived with the iOS 17 SDK and are
  back-deployed to iOS 14, so they work wherever the old names did. The
  `.navigationBar` names are marked for deprecation in their favour. `.bottomBar` is
  unchanged.
- The new names behave the same in `NavigationStack` and `NavigationSplitView`.
  Prefer them for consistency across platforms.

## 24. cornerRadius to clipShape

`.cornerRadius(_:)` is marked for deprecation (no compiler warning yet); Apple
points to `clipShape` instead.

```swift
Rectangle()
    .fill(.blue)
    .frame(width: 80, height: 80)
    .clipShape(.rect(cornerRadius: 12))

Image("cover")
    .resizable()
    .scaledToFill()
    .clipShape(.rect(cornerRadius: 20))

Color.orange
    .clipShape(.rect(cornerRadii: .init(topLeading: 16, bottomTrailing: 16)))
```

- `.rect(cornerRadius:)` is a `RoundedRectangle`.
- `.rect(cornerRadii:)` is an `UnevenRoundedRectangle` with a radius per corner.
- Both shape shorthands need iOS 17. On iOS 16, write
  `.clipShape(RoundedRectangle(cornerRadius: 12))`.

## 25. tabItem to Tab (iOS 18)

The `Tab` type inside `TabView` (iOS 18+) supersedes the `.tabItem` modifier.

```swift
// Before
TabView {
    TodayView().tabItem { Label("Today", systemImage: "sun.max") }
    HistoryView().tabItem { Label("History", systemImage: "clock") }
}

// After
TabView(selection: $selectedTab) {
    Tab("Today", systemImage: "sun.max", value: .today) { TodayView() }
    Tab("History", systemImage: "clock", value: .history) { HistoryView() }
    TabSection("Collections") {
        Tab("Favorites", systemImage: "star", value: AppTab.favorites) { FavoritesView() }
    }
}
```

- The iPadOS 18 tab sidebar needs `Tab`. `.tabItem` still compiles but its
  tabs cannot appear in the sidebar.
- Programmatic switching: give each `Tab` a `value` and bind the selection to
  `@State`.
- `TabSection` groups tabs in the sidebar. Inside it, name the selection type
  (`AppTab.favorites`, where `selectedTab` is an `AppTab`); a bare `.favorites`
  leaves `TabSection`'s generic parameter uninferred and fails to compile.

## 26. showsIndicators to scrollIndicators

`ScrollView(_:showsIndicators:)` still exists, but the `.scrollIndicators`
modifier (iOS 16+) is the consistent way to control indicators.

```swift
// Before
ScrollView(.vertical, showsIndicators: false) { timeline }

// After
ScrollView { timeline }
    .scrollIndicators(.hidden)
```

- `.scrollIndicators(_:axes:)` accepts `.automatic`, `.visible`, `.hidden` and
  `.never`.
- It also affects other scrollable views, such as `List` and `TextEditor`.
- The `axes` parameter sets horizontal and vertical indicators separately.
