Swiftui View and Uihostingcontroller in Uiscrollview Breaks Scrolling

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

Add/ expand animation will cause unwanted UIScrollView scrolling

Because of the way stack views arrange their subviews, animation can be problematic.

One approach that you may find works better is to embed the stack view in a "container" view.

That way, you can use the .isHidden property when adding an arranged subview, and allow the animation to update the "container" view:

Sample Image

The "add view" function now becomes (I added a Bool so we can skip the animation on the initial add in viewDidLoad()):

func addCustomView(_ animated: Bool) {
let customView = CustomView.instanceFromNib()

stackView.addArrangedSubview(customView)
customView.isHidden = true

if animated {
DispatchQueue.main.async {
UIView.animate(withDuration: 1) {
customView.isHidden = false
}
}
} else {
customView.isHidden = false
}
}

And we can get rid of all of the hide() / show() and zeroHeightConstraint in the custom view class:

class CustomView: UIView {

@IBOutlet weak var borderView: UIView!
@IBOutlet weak var stackView: UIStackView!

override func awakeFromNib() {
super.awakeFromNib()

borderView.layer.masksToBounds = true
borderView.layer.borderWidth = 1

}
override func layoutSubviews() {
super.layoutSubviews()
borderView.layer.cornerRadius = borderView.bounds.height * 0.5
}
}

Since it's a bit difficult to clearly show everything here, I forked your project with the changes: https://github.com/DonMag/add-expand-animation-in-scroll-view


Edit

Another "quirk" of animating a stack view shows up when adding the first arranged subview (also, when removing the last one).

One way to get around that is to add an empty view as the first subview.

So, for this example, in viewDidLoad() before adding an instance of CustomView:

let v = UIView()
stackView.addArrangedSubview(v)

This will make the first arranged subview a zero-height view (so it won't be visible).

Then, if you're implementing removing custom views, just make sure you don't remove that first, empty view.

If your stack view has .spacing = 0 noting else is needed.

If your stack view has a non-zero spacing, add another line:

let v = UIView()
stackView.addArrangedSubview(v)
stackView.setCustomSpacing(0, after: v)

SwiftUI: How to Disable List Scrolling on Reusable View

Use .disabled(true) on the list to disable interaction with the list.



Related Topics



Leave a reply



Submit