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
        }
    }
}

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