How to Build SwiftUI Views from Model Subclasses That Update

How to Build SwiftUI Views from Model Subclasses That Update

Written by

in

Quick answer: A method that returns some View has a fixed underlying type, so a base class can’t hand back different layouts per subclass, and SwiftUI can’t observe a plain class instance. Model the tag data with a protocol or enum and let a normal SwiftUI view switch on it. Also replace @State private var viewModel inside the child view with let, or new data will never reach the screen.

Why this happens

Two separate problems are tangled together here, and fixing only one leaves the view stuck.

The first is Swift. func tagView() -> some View is an opaque return type: the compiler must be able to name one concrete type for every return path in that function. A base class returning EmptyView() and a subclass returning HStack<TupleView<...>> cannot satisfy that contract, and an attempt to branch inside the function produces:

Function declares an opaque return type, but the return statements in its body do not have matching underlying types

Even if it compiled, SwiftUI would have no idea what the view depends on. Views are value types that get diffed from their stored properties. When the layout lives behind a method on a reference type, the dependency graph is hidden, so the framework has nothing to compare and nothing to invalidate.

The second problem is observation. @State private var tag: TagData tracks the reference you put in it. Assigning a fresh instance invalidates the view, but mutating val1 or s2 on the existing instance does not — a plain class publishes nothing. On top of that, seeding a child view’s input with @State private var viewModel freezes the first value that ever arrived, which is the most common reason a corrected model still shows stale text.

How to fix it

1. Replace the class hierarchy with a protocol and value types

This is the approach that scales. The model types declare what the view needs; the view knows nothing about which concrete tag produced the data.

import SwiftUI

protocol TagContent {
    var title: String { get }
    var valueText: String { get }
}

struct TagType1: TagContent {
    var val1: Int
    var title: String { "Value 1" }
    var valueText: String { String(val1) }
}

struct TagType2: TagContent {
    var s2: String
    var title: String { "String 2" }
    var valueText: String { s2 }
}

struct TagView: View {
    let content: any TagContent

    var body: some View {
        HStack {
            Text(content.title)
            Text(content.valueText)
        }
    }
}

struct ContentView: View {
    @State private var tag: any TagContent = TagType1(val1: 0)

    var body: some View {
        VStack {
            TagView(content: tag)
            Button("Read tag") {
                tag = TagType2(s2: "hello")   // assignment invalidates ContentView
            }
        }
    }
}

Note let content in TagView. An input view model must never be stored in @State, because @State captures its initial value once and ignores every later one. Use @Bindable only when the child genuinely needs to write back.

If the layouts really do differ per type, keep the shared data in the protocol and branch inside the view with @ViewBuilder, not with AnyView:

@ViewBuilder
private func layout(for content: any TagContent) -> some View {
    if content is TagType1 {
        LabeledContent(content.title, value: content.valueText)
    } else {
        HStack { Text(content.title).bold(); Text(content.valueText) }
    }
}

2. Use an enum when the set of tag shapes is closed

If you know every tag format your app supports, an enum with associated values is simpler and gives you exhaustive switches for free.

enum TagContent {
    case type1(Int)
    case type2(String)
}

struct TagView: View {
    let content: TagContent

    var body: some View {
        switch content {
        case .type1(let value):
            HStack { Text("Value 1"); Text(String(value)) }
        case .type2(let text):
            HStack { Text("String 2"); Text(text) }
        }
    }
}

A switch inside body compiles because body is already a @ViewBuilder: the compiler wraps the branches in a conditional content type for you. Hold the enum in @State on the parent and assign a new case when the NFC session delivers data.

3. If you must keep the classes, make them observable

When the model has to stay a reference type (a shared session object, for example), at least make its mutations publish. Adopt @Observable (iOS 17 and later) or ObservableObject with @Published properties, and hold it with @State or @StateObject respectively.

import Observation

@Observable
final class TagSession {
    var title: String = ""
    var valueText: String = ""
}

struct TagView: View {
    let session: TagSession

    var body: some View {
        HStack {
            Text(session.title)
            Text(session.valueText)
        }
    }
}

Only the parent that creates the object needs @State (or @StateObject for ObservableObject); children just take the instance. This fixes staleness, but it still leaves the base-class some View method impossible to implement — the subclass should not own view code either way.

4. Avoid AnyView as the escape hatch

Wrapping each subclass’s view in AnyView makes the types line up, so the base class method compiles. It also erases identity: SwiftUI can no longer tell two branches apart, transitions and animations degrade, and every redraw allocates. Use it only for a genuinely dynamic case you cannot model, never as the design.

How to prevent it

  • Keep view construction out of models. A model describes data; a View decides layout.
  • Prefer structs and enums over class inheritance for data that feeds the UI.
  • Take inputs in child views with let; reserve @State for values the view itself owns.
  • Reach for @Observable or ObservableObject the moment a reference type’s property changes must repaint the screen.
  • Use @ViewBuilder for conditional layouts and skip AnyView unless nothing else works.

FAQ

Why does my base class method returning some View fail to compile?

Because some View is an opaque type with one concrete underlying type. A base implementation returning EmptyView() and a subclass returning a stack cannot match it, and branching inside one function gives the “do not have matching underlying types” error.

My view still doesn’t refresh after I switch to a protocol. What is left?

Check how the child stores its data. @State private var viewModel inside the child seeds once and never picks up new values from the parent; change it to let, and make sure the parent assigns a new value to its @State rather than mutating the old one in place.

Can I put a tagView() requirement on a protocol instead?

You can return a concrete view type from a protocol requirement, but calling it through an existential gives any View, which does not itself conform to View, so you end up wrapping it in AnyView and losing static identity. Keeping one view that reads a protocol’s properties is simpler and keeps SwiftUI’s diffing intact.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.