I made an infinite scrolling calendar in a UIScrollView, where each may can contain up to eight subviews. The function I'm calling inside scrollViewDidScroll() looks like this (simplified):
func addAndRemoveRow(scrollView: UIScrollView) {
if scrollView.contentOffset.y - scrollViewZeroOffsetY < -heightOfDay/2 { // Going back in time.
createRowAtBottom()
removeRowAtTop()
} else if scrollView.contentOffset.y - scrollViewZeroOffsetY > heightOfDay/2 { // Going forward in time.
createRowAtTop()
removeRowAtBottom()
}
}
Functionally, this works perfectly, and in the mode where each row contains a single day, it runs smoothly even on my iPhone 5 (both modes run fluidly on the simulator). However, in the mode where each row contains a week, it's pretty choppy.
It seems like overkill to call addAndRemoveRow() every time the scrollview is moved even a pixel. Is there a way to call it less frequently?
(Alternatively, is there a more optimized way of doing this? I tried using a UICollectionView and it doesn't run any more smoothly.)
I would be curious as to why a UICollectionView didn't help. I have had collectionView's with 100's of cells on the screen at the same time and it has run very smoothly even on 5's.
If you still have your implementation of the collectionView, some things I would check to see if it can help your performance would be:
1: Make sure that you are reusing cells and that your datasource isn't changing dynamically unless it needs too. Also make sure that if you are adding more subviews to a cell that it is done in awakeFromNib instead of when you configure it and reuse your changes if possible.
2: if there is a lag on startup, use estimated size assuming your using iOS8+ and a flow layout #property (nonatomic) CGSize estimatedItemSize NS_AVAILABLE_IOS(8_0);
3: If you'r not using a flow layout make sure you are only returning the correct number of layout attributes for - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect; You should only have to return the attributes for whatever is in the rect.
4: Try not invalidating the layout on bounds change to see if it helps performance.
If you are doing more dynamic things with the view this is a good read: http://www.raizlabs.com/2013/10/animating-items-uicollectionview-2/
Related
I have a horizontally scrolled UICollectionView with a title label above it and a UIPageControl below it.
UILabel
UICollectionView
UIPageControl
When I turn on the VoiceOver accessibility feature and start traversing the screen sequentially, the collection view scrolls to the beginning or end automatically. Making the page jump off suddenly. For example, if I scroll to the 2nd page using page control, and move back to the collection view, it shows and reads the last page unexpectedly. Since I'm using the page control for navigation in the accessibility mode, I'd like to prevent the automatic scrolling.
How do I prevent or counter that?
I found an issue that seems to describe the same problem, but there's no workaround suggestion: iOS 8.4: Scroll view resets contentOffset with Voice Over enabled shortly after view appear
I encountered it on iOS 13.4.1 iPhone 11 Pro
UIScrollViewDelegate.scrollViewDidScroll(_:)
A change in the accessibility focus that triggers an automatic scrolling also triggers a call to scrollViewDidScroll(_:) in your UIScrollViewDelegate. Use that to counter the automatic scrolling effect, f.i. by setting contentOffset the way you prefer it.
You may need to detect that the scrolling was actually triggered by accessibility features, and not the user dragging or pinching. UIAccessibility.isVoiceOverRunning and UIAccessibilityFocus.accessibilityElementDidBecomeFocused() are your friends here. Beware that changing contentOffset (or zoomScale or whatever is needed) may trigger another call to scrollViewDidScroll(_:), so you need to prevent an infinite recursion.
Using #pommy's suggestions, I was able to fix my similar issue. In the code I was working on, the most appropriate place to make the change ended up being CalendarCollectionView.setContentOffset(_:animated:), where CalendarCollectionView is a UICollectionView subclass. Specifically, it's a JTACMonthView subclass, but that should not be of any relevance to this answer.
From the name, you can see my use case: a calendar which shows a month at a time. It could have many months both into the future and the past, but the usual user focus is likely to start somewhere in the middle.
Like the OP, I found that swiping from an outer element to the collection view with VoiceOver enabled caused the focus to go to the first date in the calendar, in my case 1st January 1951 (Date.farPast, I believe.) An interesting aside: Switch Control navigation did not cause the same behaviour.
The underlying behaviour was that contentOffset was getting set to 0.0 in the dimension that the collection view scrolls. In my code, that direction is held in style, and changes based on configuration, but in most applications it's likely to be fixed.
My code simply blocks any offset changes to 0.0 when VoiceOver is enabled. This is pretty naïve, and won't be suitable for all apps, but gives a concrete example which I hope will help some others!
override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
if shouldPreventAccessibilityFocusScrollback(for: contentOffset) {
return
}
super.setContentOffset(contentOffset, animated: animated)
}
func shouldPreventAccessibilityFocusScrollback(for newContentOffset: CGPoint) -> Bool {
if UIAccessibility.isVoiceOverRunning {
switch style {
case .horizontal:
return newContentOffset.x == 0
case .vertical:
return newContentOffset.y == 0
}
}
return false
}
I spent quite a long time trying to determine when UIAccessibilityFocus moved from something outside the collection view, to something inside the collection view, which is ideally the only time we want to block these automatic scrolls. I was unsuccessful, but I think that was mostly due to subclassing a third party collection view (the calendar). There's definitely more merit to that approach, if you can get it to work... but it will require some careful management of state.
On Android I have item views with potentially large widths on a horizontal scrolling list. The view loads and draws chunks "images" as parts of the view become visible on the list. This is an optimization to avoid drawing all images at once as it would be wasteful and slow. The content drawn is essentially an audio waveform. Do to the ways things need to work I can't split the chunks as individual view items on a list. This strategy works perfect because of how android drawing architecture works.
Now I'm trying to use a similar pattern in iOS but I'm having some trouble and I'm not too sure how to come up with a solution.
In iOS I'm are using a UICollectionView which draws large width Cells and we need that same optimization of loading and drawing only the visible chunks.
Solution 1:
Check what parts of the UIView is visible and draw only those visible chunks. The problem with this solution is that as the UICollectionView scrolls the UIView doesn't draw the next chucks that are becoming visible. Here are examples of what I'm talking about.
UIView loads initial chunks
These can be seen by their different chunk colors:
Scroll a bit
Black is shown and no content is loaded because there is no hint that the view needs to draw again so we can't load the next visible chunk.
Solution 2:
Use custom UIView backed by a CATiledLayer. This works perfect because it draws the tiles as they become visible while scrolling the UICollectionView.
The problem is that drawing happens on the background thread or on the next drawing cycle if shouldDrawOnMainThread is defined. This brings issues when the UIView is resized or my internal zoom logic kicks. Things shift because the drawing cycle is not synchronized to the view resizing.
So how could I get notified like CATiledLayer that a part is becoming visible and I could draw normally like a CALayer backed UIView?
UPDATE 1
I'm looking into using preferredLayoutAttributesFittingAttributes as a way to check if a new chunk need to be drawn. This is called every time the cell is moved into a new position because of scrolling. Hopefully, this isn't a bad idea. :)
- (UICollectionViewLayoutAttributes *)preferredLayoutAttributesFittingAttributes:(UICollectionViewLayoutAttributes *)layoutAttributes
UPDATE 2
After much testing and playing around. Using solution 1 is not an option. When a UIView reaches a huge width memory usage goes off the roof while using CATiledLayer memory usage is at minimal as I guess one would expect. :/
I don't know if you can completely turn off the effect. You can soften it by setting the fadeDuration to 0. To do this, you must create a subclass of CATiledLayer and overwrite the class method fadeDuration.
You can also try to implement shouldDrawOnMainThread class method, but this is a private method, and you're risking a rejection on the App Store:
#implementation MyTiledLayer
+(CFTimeInterval)fadeDuration {
return 0.0;
}
+ (BOOL)shouldDrawOnMainThread {
return YES;
}
#end
or in Swift:
class MyTiledLayer: CATiledLayer {
override class func fadeDuration() -> CFTimeInterval {
return 0.0
}
class func shouldDrawOnMainThread() -> Bool {
return true;
}
}
If you want to implement your own tiled layer instead, you should waive of the tiles and try to compute the visible part (section and zoom level) of the view. Than you can draw only this part as layer whole content.
Apparently, there is no easy way to re-draw the view when the scrollview is scrolling. But you may implement scrollViewDidScroll:, and trigger the redrawing manually. The visibleRect method always returns the complete area of the layer, but you can use
CGRect theVisibleRect = CGRectIntersection(self.frame, self.superlayer.bounds);
or in Swift
let theVisibleRect = frame.intersection(superlayer.bounds)
to determine the visible area of the layer.
I’m creating a basic parallax effect much like the iOS7 app switcher, using two UIScrollView instances (cardScrollView and tileScrollView). I scroll one alongside the other, at a different rate, like so:
if ([scrollView isEqual:self.tileScrollView]) {
[self.cardScrollView setContentOffset:CGPointMake((self.tileScrollView.contentOffset.x + 110) * TILE_CARD_DELTA,
self.cardScrollView.contentOffset.y)];
}
This works fine when scrolling tileScrollView. However, I’d like the same to work in reverse, meaning I can scroll cardScrollView and have tileScrollView move accordingly. The issue I’m having is that calling setContentOffset actually causes cardScrollView to call scrollViewDidScroll itself, meaning they’re continually trying to set each other at the same time, and all kinds of hell break loose.
Basically, the issue here is that both scrollView instances are relying on the same scrollViewDidScroll, and so I can’t differentiate between the two of them in there.
How can I get around this one?
You are getting reference of both in this method and work as per requirement :
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
if (scrollView == self.tileScrollView) {
// do something
}
else {
// do something
}
}
I'm using CPPickerView in my app to accomplish a horizontal UIPickerView, and it works great, but with large data sources (dozens of items) it scrolls very slowly which makes navigation before (especially considering a normal UIPickerView can go very fast through them).
I don't mean performance-wise, by the way, I mean the view decelerates very quickly, making traversal difficult.
It's just a subclass of UIScrollView with pagingEnabled set to YES. What can I do?
I looked in the source, and it seems CPPickerView is using a scroll view. Scroll views have a decelerationRate property. Play with that and see which value makes for the best result.
Don't fill CPPickerView with all data.
For example fill with first 20 items and if it reaches to the end add another 20.
Creator of CPPickerView here - I've recently updated CPPickerView to add an allowSlowDeceleration property, which should do what you're looking for. Check out the latest code on Github, or Cocoapods version 1.2.0.
For the purposes of documentation, here's how the solution works. Like you mentioned CPPickerView just a scrollview with pagingEnabled set to YES, so the solution I found was to disable paging when the user scrolls with enough velocity.
UIScrollViewDelegate has an optional method scrollViewWillEndDragging:withVelocity:targetContentOffset:, which is called when the user's finger is lifted after swiping/scrolling on the scrollview, and it's still called even when paging is enabled. Based on that value you can tell if the user was trying to scroll quickly through items, or just move one or two items.
I played around with the CPPickerViews in the Demo project, and found that a velocity of about 2.9f seems to be about the normal "fast swipe" threshold. So if the velocity is greater than this threshold (which I defined as kCPPickerDecelerationThreshold in CPPickerView.m) and allowSlowDeceleration is set to YES, CPPickerView now sets pagingEnabled to NO before the deceleration starts. This allows the picker to "coast" and decelerate like a normal scrollview.
It then catches the end of the deceleration, OR the user touching to stop the scroll, by the call to the scrollViewDidEndDecelerating: delegate method. The current item is determined (based on the offset of the scrollview), and then if the scrollview's pagingEnabled property is set to NO a call to the private method scrollToIndex:animated: is made with animation set to YES. This scrolls the CPPickerView to the current item, which necessary as it's unlikely the coasting scroll ended right on a page boundary.
Finally, when the animated scroll completes, the scrollViewDidEndScrollingAnimation: delegate method is called, at which point pagingEnabled is set back to YES.
If you find that you're having trouble getting it to recognize a "fast" swipe, try playing with the kCPPickerDecelerationThreshold value. In hindsight that maybe should be a customizable property, so perhaps I'll roll that into the next update.
As mentioned above you can use the decelerationRate property, setting it to UIScrollViewDecelerationRateNormal might help.
scrollView.decelerationRate = UIScrollViewDecelerationRateNormal;
If that still doesn't solve your problem you could also buffer your inputs into the scroll. See this:
Advanced scrollview techniques
I have a paging scrollview much like Apple's Page Control example project which I have adapted into a horizontal picker. I would really like the ability to scroll through many pages per flick gesture instead of one-at-a-time, much like how UIPickerViews work. Looking for some guidance on how to approach this. Thanks!
First here best Source Code
It could be that whatever is setting those numbers in there, is not greatly impressed by you setting the contentOffset under its hands. So it just goes on setting what it thinks should be the contentOffset for the next instant - without verifying if the contentOffset has changed in the meantime.
I would subclass UIScrollView and put the magic in the setContentOffset method. In my experience all content-offset changing passes through that method, even the content-offset changing induced by the internal scrolling. Just do [super setContentOffset:..] at some point to pass the message on to the real UIScrollView.
Maybe if you put your shifting action in there it will work better. You could at least detect the 3000-off setting of contentOffset, and fix it before passing the message on. If you would also override the contentOffset method, you could try and see if you can make a virtual infinite content size, and reduce that to real proportions "under the hood".
This is also helpful for you..!!!