iosswiftuiuiviewuiscrollviewuiviewrepresentable

Content offset issue in wrapped UIScrollView during transition from the bottom of the screen in SwiftUI


Description

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:

normal position of the content shifted position of the content

Replication of the problem

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?


Solution

  • 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.