I have a UIScrollView that sometimes has enough contentHeight to scroll and sometimes doesn't. I also have a button that's predicated on the user scrolling to the bottom of the scroll view.
Without the user taking an action, how do I detect if the scroll view has the contentHeight to scroll so I can set the default isEnabled of the button appropriately?
Thanks!
You can create an extension on UIScrollView
extension UIScrollView {
var contentUntilBotttom: CGFloat {
return max(contentSize.height - frame.size.height - contentOffset.y, 0)
}
var isAtTheBottom: Bool {
return contentUntilBotttom == 0
}
}
In case you are using RxSwift you can observe changes like:
extension Reactive where Base == UIScrollView {
var isAtTheBottom: ControlEvent<Bool> {
let source = contentOffset
.map({ _ -> Bool in
return self.base.isAtTheBottom
})
return ControlEvent(events: source)
}
}
// So then subscribe to it
scrollView.rx.isAtTheBottom
.subscribe(onNext: { (isAtTheBottom) in
// Update anithing you need
})
Related
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)
}
}
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.
I have a WKWebView and I want to programatically enable/disable pinch to zoom.
What should I return when I want to enable pinch to zoom?
wkWebView!.scrollView breaks with
'The view returned from viewForZoomingInScrollView: must be a subview
of the scroll view. It can not be the scroll view itself.'
func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
if pinchToZoom {
return ???
} else {
return nil
}
}
if (pinchToZoom)
{
for (UIView *subScrollView in [scrollView subviews])
{
if ([subScrollView isKindOfClass:NSClassFromString(#"WKContentView")])
{
return subScrollView;
}
}
return nil;
}
else
{
return nil;
}
My solution was to set self as webView.scrollView's delegate only when disabling zoom, and reset it to nil if zoom is enabled.
var pinchToZoom: Bool {
didSet {
// only set self as delegate when disabling zoom
webView.scrollView.delegate = pinchToZoom ? nil : self
}
}
func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
return nil
}
This way, as soon as pinchToZoom is set, the delegate updates to enable/disable zooming. No private API needed. Hope this helps!
In my tvOS app I have a TabBarController with 3 viewControllers. What I want to do is to automatically hide/change focus of the tabBar when I switch to the next viewController.
I saw some posts here, on SO that suggested to change alfa on the tabBar, but I would like to have a slide up animation, same way as it does when you change focus to something in the viewController.
Any kind of help is highly appreciated.
As Charles said.. Something like this in the derived UITabBarController:
var focusOnChildVC : Bool = false {
didSet {
self.setNeedsFocusUpdate()
}
};
override weak var preferredFocusedView: UIView? {
get {
let v : UIView?;
let focused = UIScreen.mainScreen().focusedView
//A bit of a hack but seems to work for picking up whether the VC is active or not
if (focusOnChildVC && focused != nil) {
v = self.selectedViewController?.preferredFocusedView
} else {
//If we are focused on the main VC and then clear out of property as we're done with overriding the focus now
if (focusOnChildVC) {
focusOnChildVC = false
}
v = super.preferredFocusedView;
}
return v
}
}
The basic idea of the solution described below is to subclass UITabBarController and selectively use the super implementation of weak var preferredFocusedView: UIView? { get } or one that returns selectedViewController?.preferredFocusView along with an implementation of didUpdateFocusInContext(_:withAnimationCoordinator:) that sets up an NSTimer that triggers a focus update and sets a flag that controls the preferredFocusView implementation.
More verbosely, Subclass UITabBarController and override didUpdateFocusInContext(context: UIFocusUpdateContext, withAnimationCoordinator coordinator: UIFocusAnimationCoordinator). In your implementation (make sure to call the super implementation) you can inspect the context and determine if a descendent view of the tabBar property is the nextFocusedView or the previousFocusedView (and the nextFocusedView is not a descendent).
If the tab bar is gaining focus you can create an NSTimer for the duration that you want to show the tab bar before hiding it. If the tab bar loses focus before the timer fires, invalidate it. If the timer fires, call setNeedsFocusUpdate() followed by updateFocusIfNeeded().
The last piece you need to get this to work is a flag that is set to true while the timer is set. You then need to override weak var preferredFocusedView: UIView? { get } and call the super implementation if the flag is false and if it is true return selectedViewController?.preferredFocusedView.
You can do it in a UITabBarController subclass:
final class TabBarViewController: UITabBarController {
private(set) var isTabBarHidden = false
func setTabBarHidden(_ isHidden: Bool, animated: Bool) {
guard isTabBarHidden != isHidden else {
return
}
var frame: CGRect
let alpha: CGFloat
if isHidden {
frame = tabBar.frame
frame.origin.y -= frame.height
alpha = 0
} else {
frame = tabBar.frame
frame.origin.y += frame.height
alpha = 1
}
let animations = {
self.tabBar.frame = frame
self.tabBar.alpha = alpha
}
if animated {
UIView.animate(withDuration: 0.3, animations: animations)
} else {
animations()
}
isTabBarHidden = isHidden
}
}
I have successfully implemented a search bar, now i want when swipe down the tableview to show search bar, to swipe again down, to hide search bar. What methods should i use?Thank you
A UITableView is a subclass of UIScrollView which has delegate methods (from UIScrollViewDelegate) that you can use to find out when a scroll has started and ended.
You can use the scrollViewDidScroll(_:) method to be notified when the user started scrolling, and the scrollViewDidEndDecelerating(_:) to be notified when the scroll has ended.
From your question, I assume that you already have a method to show/hide the search bar; you are just looking for "when" to call your showSearchBar or hideSearchBar method.
You could have a Bool property that stores whether the searchBar is hidden of not, and call you methods accordingly.
let searchBarIsHidden = true
override func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
if searchBarIsHidden {
showSearchBar() //your show search bar function
} else {
hideSearchBar() //your hide search bar function
}
}
Now you should make sure you update the value of searchBarIsHidden at the end of your showSearchBar and hideSearchBar
Beautiful hide and show using top constraint of search bar in Swift:
var lastContentOffset:CGFloat = 0
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let bottomOffset = scrollView.contentSize.height - scrollView.bounds.height
guard scrollView.contentOffset.y < bottomOffset else {
return
}
guard scrollView.contentOffset.y > 0 else {
searchBarTopConstraint.constant = 0
return
}
let offsetDiff = scrollView.contentOffset.y - lastContentOffset
let unsafeNewConstant = searchBarTopConstraint.constant + (offsetDiff > 0 ? -abs(offsetDiff) : abs(offsetDiff))
let minConstant:CGFloat = -searchBar.frame.height
let maxConstant:CGFloat = 0
searchBarTopConstraint.constant = max(minConstant, min(maxConstant, unsafeNewConstant))
lastContentOffset = scrollView.contentOffset.y
}