Using UIscrollview Correctly in Swiftui

SwiftUI ScrollView not properly setting content offset when nested in UIViewRepresentable

The solution was to use UIViewControllerRepresentable protocol since i was using ScrollView in a UIHostingController context.

How to scroll to position UIScrollView in Wrapper for SwiftUI?

We will first declare the offset property in the UIViewControllerRepresentable, with the propertyWrapper @Binding, because its value can be changed by the scrollview or by the parent view (the ContentView).

struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
@Binding var offset: CGPoint
init(offset: Binding<CGPoint>, @ViewBuilder content: @escaping () -> Content) {
self.content = content
_offset = offset
}
// ....//
}

If the offset changes cause of the parent view, we must apply these changes to the scrollView in the updateUIViewController function (which is called when the state of the view changes) :

func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(content())
viewController.scrollView.contentOffset = offset
}

When the offset changes because the user scrolls, we must reflect this change on our Binding. To do this we must declare a Coordinator, which will be a UIScrollViewDelegate, and modify the offset in its scrollViewDidScroll function :

class Controller: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper<Content>
init(parent: UIScrollViewWrapper<Content>) {
self.parent = parent
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.offset = scrollView.contentOffset
}
}

and, in struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable

func makeCoordinator() -> Controller {
return Controller(parent: self)
}

Finally, for the initial offset (this is important otherwise your starting offset will always be 0), this happens in the makeUIViewController:
you have to add these lines:

vc.view.layoutIfNeeded ()
vc.scrollView.contentOffset = offset

The final project :

import SwiftUI

struct ContentView: View {
@State private var offset: CGPoint = CGPoint(x: 0, y: 200)
let texts: [String] = (1...100).map {_ in String.random(length: Int.random(in: 6...20))}
var body: some View {
ZStack(alignment: .top) {
GeometryReader { geo in
UIScrollViewWrapper(offset: $offset) { //
VStack {
Text("Start")
.foregroundColor(.red)
ForEach(texts, id: \.self) { text in
Text(text)
}
}
.padding(.top, 40)

.frame(width: geo.size.width)
}
.navigationBarTitle("Test")

}
HStack {
Text(offset.debugDescription)
Button("add") {
offset.y += 100
}
}
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
.background(Color.white)
}
}
}

class UIScrollViewViewController: UIViewController {
lazy var scrollView: UIScrollView = {
let v = UIScrollView()
v.isPagingEnabled = false
v.alwaysBounceVertical = true
return v
}()

var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))

override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
pinEdges(of: scrollView, to: view)

hostingController.willMove(toParent: self)
scrollView.addSubview(hostingController.view)
pinEdges(of: hostingController.view, to: scrollView)
hostingController.didMove(toParent: self)
}

func pinEdges(of viewA: UIView, to viewB: UIView) {
viewA.translatesAutoresizingMaskIntoConstraints = false
viewB.addConstraints([
viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
])
}
}

struct UIScrollViewWrapper<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
@Binding var offset: CGPoint
init(offset: Binding<CGPoint>, @ViewBuilder content: @escaping () -> Content) {
self.content = content
_offset = offset
}

func makeCoordinator() -> Controller {
return Controller(parent: self)
}

func makeUIViewController(context: Context) -> UIScrollViewViewController {
let vc = UIScrollViewViewController()
vc.scrollView.contentInsetAdjustmentBehavior = .never
vc.hostingController.rootView = AnyView(content())
vc.view.layoutIfNeeded()
vc.scrollView.contentOffset = offset
vc.scrollView.delegate = context.coordinator
return vc
}

func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
viewController.hostingController.rootView = AnyView(content())
viewController.scrollView.contentOffset = offset
}

class Controller: NSObject, UIScrollViewDelegate {
var parent: UIScrollViewWrapper<Content>
init(parent: UIScrollViewWrapper<Content>) {
self.parent = parent
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
parent.offset = scrollView.contentOffset
}
}
}

Sample Image

SwiftUI ScrollView gesture recogniser

Using ScrollViewStyle:

struct CustomScrollView: ScrollViewStyle {
@Binding var isDragging: Bool
func make(body: AnyView, context: Context) -> some View {
body
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
class Coordinator: ScrollViewCoordinator {
var parent: CustomScrollView
init(parent: CustomScrollView) {
self.parent = parent
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
parent.isDragging = false
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
parent.isDragging = true
}
}
}

struct TestView: View {
@State var isDragging = false
var body: some View {
ScrollView {

}.scrollViewStyle(CustomScrollView(isDragging: $isDragging))
}
}

SwiftUI: Make ScrollView scrollable only if it exceeds the height of the screen

Here is a possible approach if a content of scroll view does not require user interaction (as in PO question):

Tested with Xcode 11.4 / iOS 13.4

struct StatsView: View {
@State private var fitInScreen = false
var body: some View {
GeometryReader { gp in
ScrollView {
VStack { // container to calculate total height
Text("Test1")
Text("Test2")
Text("Test3")
//ForEach(0..<50) { _ in Text("Test") } // uncomment for test
}
.background(GeometryReader {
// calculate height by consumed background and store in
// view preference
Color.clear.preference(key: ViewHeightKey.self,
value: $0.frame(in: .local).size.height) })
}
.onPreferenceChange(ViewHeightKey.self) {
self.fitInScreen = $0 < gp.size.height // << here !!
}
.disabled(self.fitInScreen)
}
}
}

Note: ViewHeightKey preference key is taken from this my solution



Related Topics



Leave a reply



Submit