Using UIScrollView correctly in SwiftUI - swift

I wanted to implement pull to refresh for Scrollview of SwiftUi and so I tried to use the UIScrollView of UIKit to make use of its refreshControl. But I am getting a problem with the UIScrollview. There are several different views inside the scrollview and all of its data is fetched from network service using different API call and once the first api data is received the first view is shown inside the scrollview, similarly when second api data is received the second view is appended to the scrollview and similarly step by step all views are added to scrollview. Now in this process of step by step adding view, the scrollview doesn't get scrolled and its contents remain hiding above the navigationbar and below the bottom toolbar (may be the scrollView height takes full height of contents). But if i load the scrollView only after all the subview data is ready all subviews are loaded together then there is no issue. But i want to load the subviews as soon as i get the first data without waiting for all the data of every subviews to load completely.
The code that I have used is -
//UIScrollView
struct HomeScrollView: UIViewRepresentable {
private let uiScrollView: UIScrollView
init<Content: View>(#ViewBuilder content: () -> Content) {
let hosting = UIHostingController(rootView: content())
hosting.view.translatesAutoresizingMaskIntoConstraints = false
uiScrollView = UIScrollView()
uiScrollView.addSubview(hosting.view)
uiScrollView.showsVerticalScrollIndicator = false
uiScrollView.contentInsetAdjustmentBehavior = .never
let constraints = [
hosting.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
hosting.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor, constant: 0),
hosting.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor, constant: 0),
hosting.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
]
uiScrollView.addConstraints(constraints)
}
func makeCoordinator() -> Coordinator {
Coordinator(legacyScrollView: self)
}
func makeUIView(context: Context) -> UIScrollView {
uiScrollView.refreshControl = UIRefreshControl()
uiScrollView.refreshControl?.addTarget(context.coordinator, action:
#selector(Coordinator.handleRefreshControl), for: .valueChanged)
return uiScrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {}
class Coordinator: NSObject {
let legacyScrollView: HomeScrollView
init(legacyScrollView: HomeScrollView) {
self.legacyScrollView = legacyScrollView
}
#objc func handleRefreshControl(sender: UIRefreshControl) {
refreshData()
}
}
}
//SwiftUi inside view's body
var body: some View {
VStack {
HomeScrollView() {
ViewOne(data: modelDataOne)
ViewTwo(data: modelDataTwo)
ViewThree(data: modelDataThree)
...
}
}
}

Related

SwiftUI: Two-finger swipe ( scroll ) gesture

I'm interested in 2-finger swipe ( scroll ) gesture.
Not two-finger drag, but 2-finger swipe (without press). Like used in Safari to scroll up and down.
As I see noone of basic gestures will work for this: TapGesture - is not; LongPressGesture - not; DragGesture - not; MagnificationGesture - not; RotationGesture - not;
Have anyone some ideas how to do this?
I need at least direction to look at.
This is MacOS project
And by the way I cannot use UI classes in my project, I cannot re-made project to catalist
With due respect to #duncan-c 's answer, the more effective way is to use the NSResponder's scrollWheel(with: NSEvent) mechanism to track two-finger scrolling (one finger on the Apple Mouse).
However it's only available under NSView, so you need to integrate it into SwiftUI using NSRepresentableView.
Here is a complete set of working code that scrolls the main image using the scroll wheel. The code uses delegates and callbacks to pass the scroll event back up the chain into SwiftUI:
//
// ContentView.swift
// ScrollTest
//
// Created by TR Solutions on 6/9/21.
//
import SwiftUI
/// How the view passes events back to the representable view.
protocol ScrollViewDelegateProtocol {
/// Informs the receiver that the mouse’s scroll wheel has moved.
func scrollWheel(with event: NSEvent);
}
/// The AppKit view that captures scroll wheel events
class ScrollView: NSView {
/// Connection to the SwiftUI view that serves as the interface to our AppKit view.
var delegate: ScrollViewDelegateProtocol!
/// Let the responder chain know we will respond to events.
override var acceptsFirstResponder: Bool { true }
/// Informs the receiver that the mouse’s scroll wheel has moved.
override func scrollWheel(with event: NSEvent) {
// pass the event on to the delegate
delegate.scrollWheel(with: event)
}
}
/// The SwiftUI view that serves as the interface to our AppKit view.
struct RepresentableScrollView: NSViewRepresentable, ScrollViewDelegateProtocol {
/// The AppKit view our SwiftUI view manages.
typealias NSViewType = ScrollView
/// What the SwiftUI content wants us to do when the mouse's scroll wheel is moved.
private var scrollAction: ((NSEvent) -> Void)?
/// Creates the view object and configures its initial state.
func makeNSView(context: Context) -> ScrollView {
// Make a scroll view and become its delegate
let view = ScrollView()
view.delegate = self;
return view
}
/// Updates the state of the specified view with new information from SwiftUI.
func updateNSView(_ nsView: NSViewType, context: Context) {
}
/// Informs the representable view that the mouse’s scroll wheel has moved.
func scrollWheel(with event: NSEvent) {
// Do whatever the content view wants
// us to do when the scroll wheel moved
if let scrollAction = scrollAction {
scrollAction(event)
}
}
/// Modifier that allows the content view to set an action in its context.
func onScroll(_ action: #escaping (NSEvent) -> Void) -> Self {
var newSelf = self
newSelf.scrollAction = action
return newSelf
}
}
/// Our SwiftUI content view that we want to be able to scroll.
struct ContentView: View {
/// The scroll offset -- when this value changes the view will be redrawn.
#State var offset: CGSize = CGSize(width: 0.0, height: 0.0)
/// The SwiftUI view that detects the scroll wheel movement.
var scrollView: some View {
// A view that will update the offset state variable
// when the scroll wheel moves
RepresentableScrollView()
.onScroll { event in
offset = CGSize(width: offset.width + event.deltaX, height: offset.height + event.deltaY)
}
}
/// The body of our view.
var body: some View {
// What we want to be able to scroll using offset(),
// overlaid (must be on top or it can't get the scroll event!)
// with the view that tracks the scroll wheel.
Image(systemName:"applelogo")
.scaleEffect(20.0)
.frame(width: 200, height: 200, alignment: .center)
.offset(offset)
.overlay(scrollView)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Edit: Correcting my answer cover Mac OS
Scrolling up and down is a NSPanGestureRecognizer. That has a numberOfTouchesRequired property that lets you make it respond to 2 fingers if desired.
Mac OS does not have a swipe gesture recognizer.
The standard UISwipeGestureRecognizer does exactly what you want. Just set numberOfTouchesRequired to 2.
...Although I'm not sure mobile Safari uses swipe gestures. It might be a 2-finger drag with some special coding.
import Combine
#main
struct MyApp: App {
#State var subs = Set<AnyCancellable>() // Cancel onDisappear
#SceneBuilder
var body: some Scene {
WindowGroup {
SomeWindowView()
/////////////
// HERE!!!!!
/////////////
.onAppear { trackScrollWheel() }
}
}
}
/////////////
// HERE!!!!!
/////////////
extension MyApp {
func trackScrollWheel() {
NSApp.publisher(for: \.currentEvent)
.filter { event in event?.type == .scrollWheel }
.throttle(for: .milliseconds(200),
scheduler: DispatchQueue.main,
latest: true)
.sink {
if let event = $0 {
if event.deltaX > 0 { print("right") }
if event.deltaX < 0 { print("left") }
if event.deltaY > 0 { print("down") }
if event.deltaY < 0 { print("up") }
}
}
.store(in: &subs)
}
}

How can I get scrolling to work for an NSCollectionView hosted in SwiftUI?

I've built an NSCollectionView wrapper using NSViewRepresentable, but it refuses to scroll. The class looks something like this:
final class SwiftNSCollectionView: NSObject, NSViewRepresentable, NSCollectionViewDataSource // etc
{
// ... init ...
typealias NSViewType = NSCollectionView
func makeNSView(context: Context) -> NSCollectionView {
let collectionView = NSCollectionView()
scrollView.documentView = collectionView
updateNSView(collectionView, context: context)
return collectionView
}
func updateNSView(_ scrollView: NSCollectionView, context: Context) {
collectionView.dataSource = self
// ... other collectionView setup
}
// ...
}
Typically, NSCollectionView has a built-in NSScrollView.
I've tried:
No wrapper—the NSCollectionView simply doesn't scroll.
Wrapping this SwiftNSCollectionView in a SwiftUI ScrollView, but that causes two problems:
The height of the the NSCollectionView collapses to 0 (which I can work around somewhat using a GeometryReader)
The NSCollectionView doesn't want to extend to the height of all its objects (which makes sense because it virtualizes them)
Using NSCollectionViewCompositionalLayoutConfiguration with an orthogonal scroll direction (hacky):
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuous
// If the "official" scroll direction is horizontal,
// then the orthogonal direction becomes vertical,
// and we can scroll our one section 😈
let configuration = NSCollectionViewCompositionalLayoutConfiguration()
configuration.scrollDirection = .horizontal
But this seemed to mess up keyboarding: after a certain number of elements, arrow keys between elements either stopped working altogether or moved to the completely wrong elements. Seemed like a virtualization bug of some sort.
You can mitigate this by creating an NSScrollView manually.
Update the NSViewType to be NSScrollView.
Update function signatures as required.
Use your existing NSCollectionView as the .documentView of the new scroll view.
Then you can use your SwiftNSCollectionView directly in SwiftUI code and it will scroll properly without any custom work on your side.
final class SwiftNSCollectionView: NSObject, NSViewRepresentable, NSCollectionViewDataSource // etc {
// ... init ...
// No longer NSCollectionView
typealias NSViewType = NSScrollView
func makeNSView(context: Context) -> NSScrollView {
// Create an NSScrollView, too!
let scrollView = NSScrollView()
let collectionView = NSCollectionView()
scrollView.documentView = collectionView
updateNSView(scrollView, context: context)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
// Since we get an NSScrollView, get the child!
let collectionView = scrollView.documentView as! NSCollectionView
collectionView.dataSource = self
// ... other collectionView setup
}
// ...
}

SwiftUI View and UIHostingController in UIScrollView breaks scrolling

When I add a UIHostingController which contains a SwiftUI view as a childView, and then place that childView inside a UIScrollView, scrolling breaks.
Here I have my View
struct TestHeightView: View {
let color: UIColor
var body: some View {
VStack {
Text("THIS IS MY TEST")
.frame(height: 90)
}
.fixedSize(horizontal: false, vertical: true)
.background(Color(color))
.edgesIgnoringSafeArea(.all)
}
}
Then I have a UIViewController with a UIScrollView as the subView. Inside the UIScrollView there is a UIStackView that is correctly setup to allow loading UIViews and scrolling through them if the stack height becomes great enough. This works. If I were to load in 40 UILabels, it would scroll through them perfectly.
The problem arises when I add a plain old UIView, and then add a UIHostingController inside that container. I do so like this:
let container = UIView()
container.backgroundColor = color.0
stackView.insertArrangedSubview(container, at: 0)
let test = TestHeightView(color: color.1)
let vc = UIHostingController(rootView: test)
vc.view.backgroundColor = .clear
add(child: vc, in: container)
func add(child: UIViewController, in container: UIView) {
addChild(child)
container.addSubview(child.view)
child.view.translatesAutoresizingMaskIntoConstraints = false
child.view.topAnchor.constraint(equalTo: container.topAnchor, constant: 0).isActive = true
child.view.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: 0).isActive = true
child.view.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 0).isActive = true
child.view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: 0).isActive = true
child.didMove(toParent: self)
}
In my example I added 3 of these containerViews/UIHostingController and then one UIView (green) to demonstrate what is happening.
You can see that as I scroll, all views are suspended as a gap is formed. What is happening is that the containing UIView (light color) is expanding its height. Once the height reaches a certain value, scrolling continues as normal until the next container/UIHostingController reaches the top and it begins again.
I have worked on several different solutions
.edgesIgnoringSafeArea(.all)
Does do something. I included it in my example because without it, the problem is exactly the same only more jarring and harder to explain using a video. Basically the same thing happens but without any animation, it just appears that the UIScrollView has stopped working, and then it works again
Edit:
I added another UIViewController just to make sure it wasn't children in general causing the issue. Nope. Only UIHostingControllers do this. Something in SwiftUI
Unbelievably this is the only answer I can come up with:
I found it on Twitter here https://twitter.com/b3ll/status/1193747288302075906?s=20 by Adam Bell
class EMHostingController<Content> : UIHostingController<Content> where Content : View {
func fixedSafeAreaInsets() {
guard let _class = view?.classForCoder else { return }
let safeAreaInsets: #convention(block) (AnyObject) -> UIEdgeInsets = { (sself : AnyObject!) -> UIEdgeInsets in
return .zero
}
guard let method = class_getInstanceMethod(_class.self, #selector(getter: UIView.safeAreaInsets)) else { return }
class_replaceMethod(_class, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
let safeAreaLayoutGuide: #convention(block) (AnyObject) ->UILayoutGuide? = { (sself: AnyObject!) -> UILayoutGuide? in
return nil
}
guard let method2 = class_getInstanceMethod(_class.self, #selector(getter: UIView.safeAreaLayoutGuide)) else { return }
class_replaceMethod(_class, #selector(getter: UIView.safeAreaLayoutGuide), imp_implementationWithBlock(safeAreaLayoutGuide), method_getTypeEncoding(method2))
}
override var prefersStatusBarHidden: Bool {
return true
}
}
Had the same issue recently, also confirm that safe area insets are breaking the scrolling. My fix on iOS 14+ with the ignoresSafeArea modifier:
public var body: some View {
if #available(iOS 14.0, *) {
contentView
.ignoresSafeArea()
} else {
contentView
}
}
I had a very similar issue and found a fix by adding the following to my UIHostingController subclass:
override func viewDidLoad() {
super.viewDidLoad()
edgesForExtendedLayout = []
}

Remove any view from any where e.g from window

I have 2 views on the screen, one is overlayView at the bottom and introView at the top of that overlayView. When I tap(tapToContinueAction) on the screen they should both hide or remove themselves.
extension UIView {
....
func hideView(view: UIView, hidden: Bool) {
UIView.transition(with: view, duration: 0.5, options: .transitionCrossDissolve, animations: {
view.isHidden = hidden
})
}
}
class IntroScreen
#IBAction func tapToContinueAction(_ sender: UITapGestureRecognizer) {
self.hideView(view: self, hidden: true)
}
--
class OverlayView : UiView {
...
}
In current situation i can hide introScreen only and i dont know how the other class's action can effect the overlayView at the same time and hide that view as well. Any idea?
You have two different classes for your views. Make an extension of your window to remove your specific views just like I have made removeOverlay and removeIntroView both these computed properties will go and search in subviews list of window and check each view with their type and remove them. Thats how you can remove any view form any where.
class OverLayView: UIView {}
class IntroView: UIView {
#IBAction func didTapYourCustomButton(sender: UIButton) {
let window = (UIApplication.shared.delegate as! AppDelegate).window!
window.removeOverlay
window.removeIntroView
}
}
extension UIWindow {
var removeOverlay: Void {
for subview in self.subviews {
if subview is OverLayView {
subview.removeFromSuperview()// here you are removing the view.
subview.hidden = true// you can hide the view using this
}
}
}
var removeIntroView: Void {
for subview in self.subviews {
if subview is IntroView {
subview.removeFromSuperview()// here you are removing the view.
subview.hidden = true// you can hide the view using this
}
}
}
}

how to have UITableView's pinned headers hide after scrolling

I want to have my tableViewHeaders visible as the user scrolls by pinning to the top which is the current behaviour in my tableView. However, when the tableView stops scrolling, I want to remove these 'pinned' headers. I am achieving this in my collectionView project using the following in my scrollView delegate methods:
if let cvl = chatCollectionView.collectionViewLayout as? UICollectionViewFlowLayout {
cvl.sectionHeadersPinToVisibleBounds = false
cvl.invalidateLayout()
}
Is there a similar way to hide a tableView's 'pinned' (sticky) headers? I am using a tableViewController.
This is my solution to this issue. I wonder if there is a simpler way to do this though.
Please note, this will only work if your header is a UITableViewHeaderFooterView. Not if you are using a UITableViewCell for a header. If you are using a UITableViewCell, tableView.headerView(forSection: indexPathForVisibleRow.section) will return nil.
In order to hide the pinned headers when the tableView stops scrolling and have them re-appear when the tableView starts scrolling again, override these four scrollView delegate methods.
In the first two (scrollViewWillBeginDragging and scrollViewWillBeginDecelerating), get the section header for the first section of the visible rows and make sure it is not hidden.
In the second two delegate methods, do a check to see that for each of the visible rows, the header frame for that row is not overlapping the frame for the row cell. If it is, then this is a pinned header and we hide it after a delay. We need to ensure that the scrollView is not still dragging before removing the pinned header as will be the case when the user lifts their finger but the scroll view continues to scroll. Also because of the time delay, we check that the scrollView is not dragging before removing it in case the user starts scrolling again less than 0.5 seconds after the scroll stops.
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
showPinnedHeaders()
}
override func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
showPinnedHeaders()
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
removePinnedHeaders()
}
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
removePinnedHeaders()
}
private func showPinnedHeaders() {
for section in 0..<totalNumberOfSectionsInYourTableView {
tableView.headerView(forSection: section)?.isHidden = false
}
}
private func removePinnedHeaders() {
if let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows {
if indexPathsForVisibleRows.count > 0 {
for indexPathForVisibleRow in indexPathsForVisibleRows {
if let header = tableView.headerView(forSection: indexPathForVisibleRow.section) {
if let cell = tableView.cellForRow(at: indexPathForVisibleRow) {
if header.frame.intersects(cell.frame) {
let seconds = 0.5
let delay = seconds * Double(NSEC_PER_SEC)
let dispatchTime = DispatchTime.now() + Double(Int64(delay)) / Double(NSEC_PER_SEC)
DispatchQueue.main.asyncAfter(deadline: dispatchTime, execute: {
if !self.tableView.isDragging && header.frame.intersects(cell.frame) {
header.isHidden = true
}
})
}
}
}
}
}
}
}
Additionally add removePinnedHeaders() to viewDidAppear() and any other rotation or keyboard frame change methods that will scroll your tableView.