I am using a custom UIScrollView with custom content in a popup view that transitions from the bottom of the screen. However, each time it animates, the content within the UIScrollView appears to offset its position when reaching the top or bottom safe area of the screen during the transition.
The issue occurs at the beginning of the animation when the UIView transitions from the bottom of the screen, right in the area of the device's safe area. Therefore, I assume the problem is related to how the UIView's content interacts with the vertical safe areas of the device.
You can observe the issue in these images:
The problem is simplified in the example below. To keep the code as simple as possible, I am not using any coordinators or additional settings for the UIScrollView in the example below.
I tried different approaches like setting the ignoreSafeArea() modifiers on UIView, setting the fixed height of the content of the UIScrollView or setting the contentInsetAdjustmentBehavior to .never for the UIScrollView but nothing solves the issue.
Testd on: XCode Version 14.2, iOS Simulator iPhone 13 Pro with iOS 16.2
struct ContentView: View {
@State private var isUIViewShowed: Bool = true
var body: some View {
VStack {
Button("Toggle the UIView", action: {
withAnimation(.linear(duration: 4)){
self.isUIViewShowed.toggle()
}
})
.frame(height: 50)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.4))
// UIView popup
.overlay(alignment: .top, content: {
if isUIViewShowed {
myUIView
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.transition(.move(edge: .bottom))
// doesn't help
// .ignoresSafeArea()
}
})
}
private var myUIView: some View {
MyUIView(content: {
Text("Some content here")
// doesn't help
// .edgesIgnoringSafeArea(.vertical)
// doesn't help
// .ignoresSafeArea()
})
.frame(height: 100, alignment: .center)
}
}
fileprivate struct MyUIView<Content: View> : UIViewRepresentable {
private let content: Content
init( @ViewBuilder content: @escaping ()->Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
let hostedView = UIHostingController(rootView: content).view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
// setting specific frame size doesn't help
// hostedView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
// doesn't help either
// scrollView.contentInsetAdjustmentBehavior = .never
// doesn't help either
// scrollView.insetsLayoutMarginsFromSafeArea = false
scrollView.addSubview(hostedView)
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
// I don't use coordinator here to keep the code as simple as possible
func makeCoordinator() -> Coordinator {}
}
What am I missing here? Is there a better approach to defining a custom UIView in SwiftUI that prevents such a behavior?
I came up with a solution by defining constraints for the UIView, thanks to this post: https://stackoverflow.com/a/62392498/19954370 .
scrollView.contentInsetAdjustmentBehavior = .never
hostedView.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostedView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
hostedView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
hostedView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
]
scrollView.addConstraints(constraints)
The entire fixed code looks like this. But please note that this is the bare minimum to demonstrate the problem I faced, and for the completely working UIScrollView, you need to add a custom coordinator and define the contentSize of the scrollView in MakeUIView method.
struct ContentView: View {
@State private var isUIViewShowed: Bool = true
var body: some View {
VStack {
Button("Toggle the UIView", action: {
withAnimation(.linear(duration: 4)){
self.isUIViewShowed.toggle()
}
})
.frame(height: 50)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.4))
// UIView popup
.overlay(alignment: .top, content: {
if isUIViewShowed {
myUIView
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.transition(.move(edge: .bottom))
}
})
}
private var myUIView: some View {
MyUIView(content: {
Text("Some content here")
// added fixed height to the content of the UIView
.frame(height: 100, alignment: .center)
})
.frame(height: 100, alignment: .center)
}
}
fileprivate struct MyUIView<Content: View> : UIViewRepresentable {
private let content: Content
init( @ViewBuilder content: @escaping ()->Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
let hostedView = UIHostingController(rootView: content).view!
scrollView.addSubview(hostedView)
// adding contraints fixed the shifting behaviour
scrollView.contentInsetAdjustmentBehavior = .never
hostedView.translatesAutoresizingMaskIntoConstraints = false
let constraints = [
hostedView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
hostedView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
hostedView.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
]
scrollView.addConstraints(constraints)
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
// I don't use coordinator here to keep the code as simple as possible
func makeCoordinator() -> Coordinator {}
}
I would appreciate any corrections or better approaches to the answer.