SwiftUI onChange Deprecation

In iOS 17, Apple deprecated the onChange(of:perform) view modifier replacing it with two new variations.

Pre-iOS 17 onChange

Before iOS 17, you use the onChange(of:perform:) view modifier to run an action when a property of a view changes. For example, here’s a list view that scrolls to the last item each time the count of items in the list changes:

struct ListView: View {
  @EnvironmentObject private var store: ItemStore

  var count: Int {
    store.itemCount
  }

  var body: some View {
    ScrollViewReader { proxy in
      List {
        ForEach(store.items) { item in
          NavigationLink(value: item) { ItemRow(item: item) }
        }
      }
      .onChange(of: count) { newCount in
        if let lastID = store.lastItemID() {
          withAnimation {
            proxy.scrollTo(lastID)
          }
        }
      }
    }
  }
}

The closure has a single argument for the new value of the count. If I only want to scroll when the count increases I need to capture the value before the state changes:

.onChange(of: count) { [count] newCount in
  if newCount > count,
   let lastID = store.lastItemID {
    withAnimation {
      proxy.scrollTo(lastID)
    }
  }
}

Note: The closure captures the value before the change.

onChange in iOS 17

Once you update the minimum deployment target of your app to iOS 17 Xcode shows a deprecation warning for any use of the old onChange handler:

onChange of perform was deprecated in iOS 17.0: Use onChange with a two or zero parameter action closure instead.

You have two choices depending on whether you want the value that’s changed or not. For situations where you don’t care about the value use the closure with zero arguments:

.onChange(of: count) {
  if let lastID = store.lastItemID {
    withAnimation {
      proxy.scrollTo(lastID)
    }
  }
}

The alternate version gives you both the old and new values of the tracked value:

.onChange(of: count, initial: true) { oldCount, newCount in
  if newCount > oldCount,
   let lastID = store.lastItemID {
    withAnimation {
      proxy.scrollTo(lastID)
    }
  }
}

Initial Action

Both these new methods have a boolean initial property that defaults to false. When true the action runs when the view initially appears. For example, if I want to scroll to the end of the list when the view appears:

.onChange(of: count, initial: true) { oldCount, newCount in
  if newCount >= oldCount,
   let lastID = store.lastItemID {
    withAnimation {
      proxy.scrollTo(lastID)
    }
  }
}

Note that on the initial run the old and new values are the same.