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
}
}
}
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:
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
Send Mail with File Attachment
How to Sort Dates in a Dictionary
Textfield in Swiftui Loses Focus When I Enter a Character
How to Use Nsvisualeffectview to Blend Window with Background
Zposition of Sknode Relative to Its Parent
Ios: Ambiguous Use of Init(Cgimage)
Alamofire Returns Wrong Encoding
Is Gamescene.Sks Not Recommended for Game Building
How to Switch an Xcode Project to Use Swift Version 1.2 in the Xcode 7 Beta
How to Cast [Int8] to [Uint8] in Swift
Non-Modular Headers of Openssl Library When Using Modulemap for Swift Framework
C-Style Uninitialized Pointer Passing in Apple Swift
Differences Between Filtering Realm with Nspredicate and Block
Swiftui Share Sheet Crashes iPad