I have a UIScrollView which scrolls automatically by setting its content offset within via a UIViewPropertyAnimator. The auto-scrolling is working as expected, however I also want to be able to interrupt the animation to scroll manually.
This seems to be one of the selling points of UIViewPropertyAnimator:
...dynamically modify your animations before they finish
However it doesn't seem to play nicely with scroll views (unless I'm doing something wrong here).
For the most part, it is working. When I scroll during animation, it pauses, then resumes once deceleration has ended. However, as I scroll towards the bottom, it rubber bands as if it is already at the end of the content (even if it is nowhere near). This is not an issue while scrolling towards the top.
Having noticed this, I checked the value of scrollView.contentOffset and it seems that it is stuck at the maximum value + the rubber banding offset. I found this question/answer which seems to be indicate this could be a bug with UIViewPropertyAnimator.
My code is as follows:
private var maxYOffset: CGFloat = .zero
private var interruptedFraction: CGFloat = .zero
override func layoutSubviews() {
super.layoutSubviews()
self.maxYOffset = self.scrollView.contentSize.height - self.scrollView.frame.height
}
private func scrollToEnd() {
let maxOffset = CGPoint(x: .zero, y: self.maxYOffset)
let duration = (Double(self.script.wordCount) / Double(self.viewModel.wordsPerMinute)) * 60.0
let animator = UIViewPropertyAnimator(duration: duration, curve: .linear) {
self.scrollView.contentOffset = maxOffset
}
animator.startAnimation()
self.scrollAnimator = animator
}
extension UIAutoScrollView: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// A user initiated pan gesture will begin scrolling.
if let scrollAnimator = self.scrollAnimator, self.viewModel.isScrolling {
self.interruptedFraction = scrollAnimator.fractionComplete
scrollAnimator.pauseAnimation()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if let scrollAnimator = self.scrollAnimator, self.viewModel.isScrolling {
scrollAnimator.startAnimation()
}
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if let scrollAnimator = self.scrollAnimator, self.viewModel.isScrolling {
scrollAnimator.startAnimation()
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
switch scrollView.panGestureRecognizer.state {
case .changed:
// A user initiated pan gesture triggered scrolling.
if let scrollAnimator = self.scrollAnimator {
let fraction = (scrollView.contentOffset.y - self.maxYOffset) / self.maxYOffset
let boundedFraction = min(max(.zero, fraction), 1)
scrollAnimator.fractionComplete = boundedFraction + self.interruptedFraction
}
default:
break
}
}
}
Is there anywhere obvious I'm going wrong here? Or any workarounds I can employ to make the scroll view stop rubber banding on scroll downwards?
You can add tap Gesture Recognizer and call this function,
extension UIScrollView {
func stopDecelerating() {
let contentOffset = self.contentOffset
self.setContentOffset(contentOffset, animated: false)
}
}
Related
When loading posts, the most recent post is usually at the top of the screen and as the user swipes up, once the scrollView hits the bottom of the screen older posts shown are using this method
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset.y
let maximumOffset = scrollView.contentSize.height - scrollView.frame.size.height
if maximumOffset - contentOffset <= 40 {
handlePagination()
}
}
But when loading messages it's the exact opposite. The most recent message is at the bottom of the screen and as the user swipes down once the scrollView hits the top of the screen older messages are shown.
There are 2 situations to take into account:
1- there is usually a textField or a textView with a sendButton at the bottom of the screen. If the user doesn't touch the textField/ or textView, they can just swipe down and once the scrollView hits the top of the screen handlePagination() will get called to show older messages
2- If the user touches the textView/Field, the keyboard is raised, even if they type some text into the textView/Field they can still swipe down to view older messages
The questions are
1. How can I detect when the user scrolls down so I can call handlePaginate() when the scrollView hits the top of the screen
and
2. Do I need to take the keyboard's height into consideration when it is raised while doing that?
I can use the keyboard notifications to detect when the keyboard is or isn't raised and simply toggle a property to the keyboard's height.
var keyboardHeight: CGFloat = 0.0
#objc fileprivate func keyboardWillShow(notification: Notification) {
guard let keyboardDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { return }
guard let keyboardFrame: NSValue = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }
let keyboardRectangle = keyboardFrame.cgRectValue
keyboardHeight = keyboardRectangle.height
}
#objc fileprivate func keyboardWillHide(notification: Notification) {
keyboardHeight = 0.0
}
Seems I only had to check if the contentOffset was less then or equal to 50
if contentOffset <= -40 {
handlePagination()
}
It worked when the keyboard was and wasn't raised.
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
let contentOffset = scrollView.contentOffset.y
if contentOffset <= -40 {
handlePagination()
}
}
I have a chat app and I'm trying to show a custom view that I've made when the user scrolls to the top, also hide it if it's on the bottom of tableview. (like whatsapp does it)
To be honest I'm struggling with the logic of show/hide button.
Tried to save the contentOffset.y of my tableview right after I reload the data so I'll know that's the bottom, and if it's smaller to show the custom view, but mainTableView.contentOffset.y it's always 0.
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
if (scrollView == mainTableView) {
print(mainTableView.contentOffset.y)
if let point = startingPointForView {
//where var startingPointForView: CGFloat?
// and tried to save it after I reload the data
//self.startingPointForView = self.mainTableView.contentOffset.y
// but it's always 0
}
// Show and hide button logic
}
}
An image of what I m trying to achieve: https://imgur.com/ZkYEi2P
try this code to hide/show custom view according to UIscrollview contentOffset
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let scrollViewContentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.frame.height
if scrollView.contentOffset.y < (scrollViewContentHeight - scrollViewHeight){
//Custom view show
}else{
//Custom view Hide
}
}
May be this code will help you
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView.panGestureRecognizer.translation(in: scrollView).y > 0 {
// down
button.isHidden = false
} else {
// up
button.isHidden = true
}
}
For someone who is looking to hide a button when tableview is scrolling can use below code:
var previousContentOffset: CGFloat = CGFloat()
extension YourViewController: UIScrollViewDelegate{
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == self.yourTableView{
let currentContentOffset = scrollView.contentOffset.y
if (currentContentOffset > previousContentOffset) {
// scrolling towards the bottom
if scrollView.contentOffset.y > 50 {
self.yourButton.isHidden = true
} else {
self.yourButton.isHidden = false
}
} else if (currentContentOffset < previousContentOffset) {
// scrolling towards the top
let maximumOffset = scrollView.contentSize.height - scrollView.frame.size.height
// Change 10.0 to adjust the distance from bottom
if maximumOffset - currentContentOffset <= 10.0 {
self.yourButton.isHidden = true
} else {
self.yourButton.isHidden = false
}
}
previousContentOffset = currentContentOffset
}
}
}
In the UIScrollViewDelegate scrollViewDidScroll(UIScrollView) function, if you change the frame of the delegate's UIScrollView, then scrollViewDidScroll(UIScrollView) gets called again. A common example of this is when you have a header that shrinks as the user scrolls up and expands as the user scrolls down.
Aside from an implementation in which you unset and reset the scrollView's delegate property like below, are there any ways to prevent recursion? The UIScrollView object has isTracking and isDragging properties that overlap, and also remain true in the below example.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let d = scrollView.delegate // Prevent recursion by setting the
scrollView.delegate = nil
// Do stuff
if someCondition == true {
scrollView.contentOffset = somePoint
scrollView.frame = someRect
}
scrollView.delegate = d // Restore the scrollView's delegate
}
NOTE: I DO NOT NEED A TUTORIAL FOR MAKING A COLLAPSIBLE HEADER, NOR DO I NEED A SIMPLE IF CHECK. I'm only specifically asking what options are available for breaking out of scrolling events based on their origin. For example, you could put this at the start of scrollViewDidScroll:
if(!scrollView.isTracking && !scrollView.isDragging) {
// Break out if we got here through anything but a touch event
return
}
I just tried it, and setting .contentOffset does not cause recursion.
As you drag the content, the delegate receives continuous scrollViewDidScroll messages... when you call:
scrollView.contentOffset = CGPoint(x: 0, y: 0)
you are still dragging, and the content keeps getting reset to 0,0.
Change your function to this:
var i = 0
func scrollViewDidScroll(_ scrollView: UIScrollView) {
i += 1
print("did scroll", i)
scrollView.contentOffset = CGPoint.zero
}
and you will see it stops counting when you stop dragging - so it is not recursive.
Perhaps you really want to use scrollViewDidEndDragging or scrollViewDidEndDecelerating?
Edit:
Perhaps this will work better for you. Keep in mind, as long as you continue to drag inside the scroll view, scrollViewDidScroll() will continue to be called.
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
print("did end dragging", scrollView.contentOffset)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
print("did scroll", scrollView.contentOffset)
scrollView.setContentOffset(CGPoint.zero, animated: false)
}
If you look at the console output, you should never see more than 2 print()s after you "stop dragging".
I want to stop backward scrolling on ScrollView after user scrolls to the next page. How can I do that.
I tried the following two codes, but the first one does not have any effect
self.scrollView.contentOffset = CGPointMake(self.view.frame.width,0)
and the second only disables the forward scrolling.
self.scrollView.contentSize = CGSizeMake( 2 * scrollWidth, scrollHeight);
To disable scrolling in one direction you implement the UIScrollViewDelegate method scrollViewDidScroll and put your logic there. For instance this TableViewController can only ever scroll down, because if the user tries to scroll up, we just overwrite the contentOffset, effectively undoing their scroll before they see it.
class ViewController: UITableViewController {
var lastScrollPosition = CGPoint.zero
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentOffset.y > lastScrollPosition.y else {
scrollView.setContentOffset(lastScrollPosition, animated: false)
return
}
lastScrollPosition = scrollView.contentOffset
}
}
If your cell is equal in size to your screen, you can apply the following option, which is very smooth:
var lastScrollPosition = CGPoint.zero
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if scrollView.contentOffset.x == lastScrollPosition.x + UIScreen.main.bounds.width {
lastScrollPosition.x += UIScreen.main.bounds.width
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.contentOffset.x > lastScrollPosition.x else {
scrollView.setContentOffset(lastScrollPosition, animated: false)
return
}
}
I am trying to set the content offset in my uiscrollview. I have tried the following, and none of it works. None of the following affect my scrollView in any way, it simple appears on the screen as normal.:
let point = CGPoint(x: 0, y: self.view.frame.height / 2)
self.scrollView.setContentOffset(point, animated: true)
and:
self.scrollView.contentOffset.y = self.view.frame.size.height / 2
and:
self.scrollView.contentOffset = CGPointMake(0, self.view.frame.size.height / 2)
and:
self.scrollView.contentOffset = CGPointMake(0, scrollView.frame.size.height / 2)
I have a scroll view that is 2 times the height of my view. They are essentially separated into 2 different view that I can page between. I want the scrollview to start on the bottom part.
You should try them in the viewDidLayoutSubviews
Ref: iOS 9 scroll UITextView to top
I was really scratching my head when I found that setting scrollView.contentOffset to any value was not working. Eventually I realized that I was calling the scrollView delegate message 'scrollViewDidScroll' to manage scroll dragging peculiarities.
It turns out that 'scrollViewDidScroll' is called whenever the scrollView contentOffset is changed, and my code to manage drag issues was changing the contentOffset. I just made a 'BOOL isDragging' variable to only make changes to the contentOffset when dragging:
private var isDragging : Bool = false
private var initialContentOffset = CGPoint.zero
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isDragging = true
...
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
// need to keep track if we are dragging with isDragging
// since this 'scrollViewDidScroll' API is called with ANY contentOffset change
if (isDragging == true){
// do stuff to contentOffset to manage dragging peculiarities
...
}
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if (decelerate == false){
scrollViewDidEndScrollingAnimation(scrollView)
}
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollViewDidEndScrollingAnimation(scrollView)
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
isDragging = false
}