Backstory - I wanted to create a SwiftUI List with a collapsible header. I came up a solution that I will show below in code
In short, there's an empty section the size of the header in the List, based on which I get an offset while scrolling and can move the header accordingly
And it has been working great, except the only problem so far is that if you have a screen with this component and there's another screen presented modally that causes modifications of the content of the underlying screen with a List, this solution produces a random offset value of 39.5333333333333
The value is exactly the same no matter the size of the header I put in there, and I can't see what is causing it. This ONLY happens when modification happens from a modal screen. The style of the List also doesn't matter, bug happens on all of them
So far I found that it happens due to us using UIKit navigation controllers in our app. Shown below are two examples of code, one with UIKit navigation that has the bug and one fully SwiftUI that doesn't. The screen itself has no difference whatsoever
UIKit:
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
class Coordinator: UINavigationController {
lazy var viewModel = ContentViewModel()
init() {
super.init(nibName: nil, bundle: nil)
let vc = UIHostingController(rootView: ContentView(viewModel: self.viewModel))
vc.navigationItem.rightBarButtonItems = [
UIBarButtonItem(
title: "Show Modal",
style: .plain,
target: self,
action: #selector(showModal)
)
]
self.setViewControllers([vc], animated: false)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
func showModal() {
self.present(UIHostingController(rootView: ModalView(viewModel: self.viewModel)), animated: true)
}
}
class ContentViewModel: ObservableObject {
@Published var items: [UUID] = []
init() {
self.random()
}
func random() {
self.items = (1..<100).map { _ in UUID() }
}
}
struct ContentView: View {
@ObservedObject var viewModel: ContentViewModel
@State var headerSize: CGSize = .zero
var body: some View {
ZStack {
self.listView
VStack {
self.header
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) {
self.headerSize = $0
}
Spacer()
}
}
}
@ViewBuilder
var listView: some View {
GeometryReader { proxy in
List {
Section(
header: Color.red
.frame(height: self.headerSize.height)
.listRowInsets(EdgeInsets())
.anchorPreference(key: ScrollOffsetPreferenceKey.self, value: .bounds) {
proxy[$0].origin
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: self.handleOffsetChange)
) {}
Section() {
ForEach(self.viewModel.items, id: \.self) {
Text($0.uuidString)
}
}
}
.listStyle(GroupedListStyle())
}
}
@ViewBuilder
var header: some View {
Text("Test text, hello")
.padding()
.frame(maxWidth: .infinity)
}
func handleOffsetChange(to offset: CGPoint) {
debugPrint("\(offset.y)")
}
}
struct ModalView: View {
@ObservedObject var viewModel: ContentViewModel
var body: some View {
Button("Do stuff") {
self.viewModel.random()
}
}
}
SwiftUI (preferences and view model is the same, removed due to character limit):
struct ContentView: View {
@ObservedObject var viewModel: ContentViewModel
@State var showingSheet = false
@State var headerSize: CGSize = .zero
var body: some View {
NavigationView {
ZStack {
self.listView
VStack {
self.header
.background(
GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self) {
self.headerSize = $0
}
Spacer()
}
}
.sheet(isPresented: self.$showingSheet) {
ModalView(viewModel: self.viewModel)
}
.navigationTitle("Test")
.navigationBarItems(
trailing:
Button("Show modal") {
self.showingSheet.toggle()
}
)
}
}
@ViewBuilder
var listView: some View {
GeometryReader { proxy in
List {
Section(
header: Color.red
.frame(height: self.headerSize.height)
.listRowInsets(EdgeInsets())
.anchorPreference(key: ScrollOffsetPreferenceKey.self, value: .bounds) {
proxy[$0].origin
}
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: self.handleOffsetChange)
) {}
Section() {
ForEach(self.viewModel.items, id: \.self) {
Text($0.uuidString)
}
}
}
.listStyle(GroupedListStyle())
}
}
@ViewBuilder
var header: some View {
Text("Test text, hello")
.padding()
.frame(maxWidth: .infinity)
}
func handleOffsetChange(to offset: CGPoint) {
debugPrint("\(offset.y)")
}
}
struct ModalView: View {
@ObservedObject var viewModel: ContentViewModel
var body: some View {
Button("Do stuff") {
self.viewModel.random()
}
}
}
Does anyone have an idea of how to fix this?