Skip to content

Instantly share code, notes, and snippets.

@aronbalog
Created March 17, 2020 10:57
Show Gist options
  • Save aronbalog/2fade2ae3f9fa61dff0854aa661d20a6 to your computer and use it in GitHub Desktop.
Save aronbalog/2fade2ae3f9fa61dff0854aa661d20a6 to your computer and use it in GitHub Desktop.
UIScrollView Wrapper for SwiftUI
import SwiftUI
public struct ScrollViewWrapper<Content: View>: UIViewRepresentable {
@Binding var contentOffset: CGPoint
let content: () -> Content
public init(contentOffset: Binding<CGPoint>, @ViewBuilder _ content: @escaping () -> Content) {
self._contentOffset = contentOffset
self.content = content
}
public func makeUIView(context: UIViewRepresentableContext<ScrollViewWrapper>) -> UIScrollView {
let view = UIScrollView()
view.delegate = context.coordinator
let controller = UIHostingController(rootView: content())
controller.view.sizeToFit()
view.addSubview(controller.view)
view.contentSize = controller.view.bounds.size
return view
}
public func updateUIView(_ uiView: UIScrollView, context: UIViewRepresentableContext<ScrollViewWrapper>) {
uiView.contentOffset = self.contentOffset
}
public func makeCoordinator() -> Coordinator {
Coordinator(contentOffset: self._contentOffset)
}
public class Coordinator: NSObject, UIScrollViewDelegate {
let contentOffset: Binding<CGPoint>
init(contentOffset: Binding<CGPoint>) {
self.contentOffset = contentOffset
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
contentOffset.wrappedValue = scrollView.contentOffset
}
}
}
@scottmas
Copy link

scottmas commented Dec 1, 2023

Slightly beefed up:

import SwiftUI

public struct ScrollViewWrapper<Content: View>: UIViewRepresentable {
    @Binding var contentOffset: CGPoint
    @Binding var scrollViewHeight: CGFloat
    @Binding var visibleHeight: CGFloat
    
    let content: () -> Content
    
    public init(
        contentOffset: Binding<CGPoint>,
        scrollViewHeight: Binding<CGFloat>,
        visibleHeight: Binding<CGFloat>,
        @ViewBuilder _ content: @escaping () -> Content) {
            self._contentOffset = contentOffset
            self._scrollViewHeight = scrollViewHeight
            self._visibleHeight = visibleHeight
            
            self.content = content
        }
    
    public func makeUIView(context: UIViewRepresentableContext<ScrollViewWrapper>) -> UIScrollView {
        let view = UIScrollView()
        view.delegate = context.coordinator
        
        // Instantiate the UIHostingController with the SwiftUI view
        let controller = UIHostingController(rootView: content())
        controller.view.translatesAutoresizingMaskIntoConstraints = false  // Disable autoresizing
        view.addSubview(controller.view)
        
        // Set constraints for the controller's view
        NSLayoutConstraint.activate([
            controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            controller.view.topAnchor.constraint(equalTo: view.topAnchor),
            controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            controller.view.widthAnchor.constraint(equalTo: view.widthAnchor)  // Ensures the width matches the scroll view
        ])
        
        return view
    }
    
    public func updateUIView(_ uiView: UIScrollView, context: UIViewRepresentableContext<ScrollViewWrapper>) {
        uiView.contentOffset = self.contentOffset
        
        DispatchQueue.main.async {
            self.scrollViewHeight = uiView.contentSize.height
            self.visibleHeight = uiView.frame.size.height
            
            // Update the frame of the hosted view if necessary
            if let hostedView = uiView.subviews.first {
                hostedView.frame = CGRect(origin: .zero, size: uiView.contentSize)
            }
        }
    }
    
    public func makeCoordinator() -> Coordinator {
        Coordinator(contentOffset: self._contentOffset, scrollViewHeight: self._scrollViewHeight) // Modify this line
    }
    
    public class Coordinator: NSObject, UIScrollViewDelegate {
        let contentOffset: Binding<CGPoint>
        let scrollViewHeight: Binding<CGFloat>  // Add this line
        
        init(contentOffset: Binding<CGPoint>, scrollViewHeight: Binding<CGFloat>) { // Modify this line
            self.contentOffset = contentOffset
            self.scrollViewHeight = scrollViewHeight  // Add this line
        }
        
        public func scrollViewDidScroll(_ scrollView: UIScrollView) {
            contentOffset.wrappedValue = scrollView.contentOffset
        }
    }
}

Used like so:

struct ContentView: View {
    @State private var contentOffset: CGPoint = .zero
    @State private var scrollViewHeight: CGFloat = .zero
    @State private var scrollViewVisibleHeight: CGFloat = .zero
    
    var body: some View {
        ScrollViewWrapper(contentOffset: $contentOffset, scrollViewHeight: $scrollViewHeight, visibleHeight: $scrollViewVisibleHeight) {
            VStack {
                ForEach(0..<400, id: \.self) { index in
                    Text("Item \(index)")
                        .padding()
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .onChange(of: contentOffset) { newOffset in
            let scrollPercent = newOffset.y / (self.scrollViewHeight - self.scrollViewVisibleHeight)
            if(scrollPercent.isFinite){
                print("Scroll: \(scrollPercent)")
            }
            
        }
    }
}

@squinney
Copy link

This was helpful for me, though I had an issue with it updating when content was changed. I added a few lines so that UIHostingController is properly updated on changes:

public struct ScrollViewWrapper<Content: View>: UIViewRepresentable {
    @Binding var contentOffset: CGPoint
    @Binding var scrollViewHeight: CGFloat
    @Binding var visibleHeight: CGFloat
    
    let content: () -> Content
    
    public init(
        contentOffset: Binding<CGPoint>,
        scrollViewHeight: Binding<CGFloat>,
        visibleHeight: Binding<CGFloat>,
        @ViewBuilder _ content: @escaping () -> Content) {
            self._contentOffset = contentOffset
            self._scrollViewHeight = scrollViewHeight
            self._visibleHeight = visibleHeight
            
            self.content = content
        }
    
    public func makeUIView(context: UIViewRepresentableContext<ScrollViewWrapper>) -> UIScrollView {
        let view = UIScrollView()
        view.delegate = context.coordinator
        
        let controller = UIHostingController(rootView: content())
        controller.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(controller.view)
        
        NSLayoutConstraint.activate([
            controller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            controller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            controller.view.topAnchor.constraint(equalTo: view.topAnchor),
            controller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            controller.view.widthAnchor.constraint(equalTo: view.widthAnchor)
        ])
        
        context.coordinator.hostingController = controller
        
        return view
    }
    
    public func updateUIView(_ uiView: UIScrollView, context: UIViewRepresentableContext<ScrollViewWrapper>) {
        uiView.contentOffset = self.contentOffset
        
        if let hostingController = context.coordinator.hostingController {
            hostingController.rootView = content()
        }
        
        DispatchQueue.main.async {
            self.scrollViewHeight = uiView.contentSize.height
            self.visibleHeight = uiView.frame.size.height
            
            if let hostedView = uiView.subviews.first {
                hostedView.frame = CGRect(origin: .zero, size: uiView.contentSize)
            }
        }
    }
    
    public func makeCoordinator() -> Coordinator {
        Coordinator(contentOffset: self._contentOffset, scrollViewHeight: self._scrollViewHeight)
    }
    
    public class Coordinator: NSObject, UIScrollViewDelegate {
        let contentOffset: Binding<CGPoint>
        let scrollViewHeight: Binding<CGFloat>
        var hostingController: UIHostingController<Content>?
        
        init(contentOffset: Binding<CGPoint>, scrollViewHeight: Binding<CGFloat>) {
            self.contentOffset = contentOffset
            self.scrollViewHeight = scrollViewHeight
        }
        
        public func scrollViewDidScroll(_ scrollView: UIScrollView) {
            contentOffset.wrappedValue = scrollView.contentOffset
        }
    }
}

@EngOmarElsayed
Copy link

one more thing layoutIfNeeded must be called before returing the view because otherwise the view may freeze when updating the content in the scrollView

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment