UIScrollView + LargeTitle (iOS 11) - scroll to top and reveal the large title - ios

I use the following code to scroll to top of the UICollectionView:
scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: true)
However, on iOS 11 and 12 the scrollView only scrolls to the top, without revealing the large title of the UINavigationBar (when prefersLargeTitle has ben set to true.)
Here is how it looks like:
The result I want to achieve:

It works as it is designed, you are scrolling to position y = 0, assign your controller to be UIScrollView delegate and print out scroll offset:
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
print(scrollView.contentOffset)
}
You will see when Large title is displayed and you move your scroll view a but and it jumps back to the Large title it will not print (0.0, 0.0) but (0.0, -64.0) or (0.0, -116.0) - this is the same value as scrollView.adjustedContentInset, so if you want to scroll up and display large title you should do:
scrollView.scrollRectToVisible(CGRect(x: 0, y: -64, width: 1, height: 1), animated: true)

You don't want to use any 'magic values' (as -64 in the currently accepted answer). These may change (also, -64 isn't correct anyway).
A better solution is to observe the SafeAreaInsets changes and save the biggest top inset. Then use this value in the setContentOffset method. Like this:
class CollectioViewController: UIViewController {
var biggestTopSafeAreaInset: CGFloat = 0
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
self.biggestTopSafeAreaInset = max(ui.safeAreaInsets.top, biggestTopSafeAreaInset)
}
func scrollToTop(animated: Bool) {
ui.scrollView.setContentOffset(CGPoint(x: 0, y: -biggestTopSafeAreaInset), animated: animated)
}
}

It seems that using a negative content offset is the way to go.
I really like the idea of Demosthese to keep track of the biggest top inset.
However, there is a problem with this approach.
Sometime large titles cannot be displayed, for example, when an iPhone is in landscape mode.
If this method is used after a device has been rotated to landscape then the offset of the table will be too much because the large title is not displayed in the navigation bar.
An improvements to this technique is to consider biggestTopSafeAreaInset only when the navigation bar can display a large title.
Now the problem is to understand when a navigation bar can display a large title.
I did some test on different devices and it seems that large titles are not displayed when the vertical size class is compact.
So, Demosthese solution can be improved in this way:
class TableViewController: UITableViewController {
var biggestTopSafeAreaInset: CGFloat = 0
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
self.biggestTopSafeAreaInset = max(view.safeAreaInsets.top, biggestTopSafeAreaInset)
}
func scrollToTop(animated: Bool) {
if traitCollection.verticalSizeClass == .compact {
tableView.setContentOffset(CGPoint(x: 0, y: -view.safeAreaInsets.top), animated: animated)
} else {
tableView.setContentOffset(CGPoint(x: 0, y: -biggestTopSafeAreaInset), animated: animated)
}
}
}
There is still a case that could cause the large title to not be displayed after the scroll.
If the user:
Open the app with the device rotated in landscape mode.
Scroll the view.
Rotate the device in portrait.
At this point biggestTopSafeAreaInset has not yet had a chance to find the greatest value and if the scrollToTop method is called the large title will be not displayed.
Fortunately, this is a case that shouldn't happen often.

Quite late here but I have my version of the story.
Since iOS 11 there is the adjustedContentInset on the scroll view.
That however reflects only the current state of the UI thus if the large navigation title is not revealed, it won't be taken into account.
So my solution is to make couple of extra calls to make the system consider the large title size and calculate it to the adjustedContentInset:
extension UIScrollView {
func scrollToTop(animated: Bool = true) {
if animated {
// 1
let currentOffset = contentOffset
// 2
setContentOffset(CGPoint(x: 0, y: -adjustedContentInset.top - 1), animated: false)
// 3
let newAdjustedContentInset = adjustedContentInset
// 4
setContentOffset(currentOffset, animated: false)
// 5
setContentOffset(CGPoint(x: 0, y: -newAdjustedContentInset.top), animated: true)
} else {
// 1
setContentOffset(CGPoint(x: 0, y: -adjustedContentInset.top - 1), animated: false)
// 2
setContentOffset(CGPoint(x: 0, y: -adjustedContentInset.top), animated: false)
}
}
}
Here is what's happening:
When animated:
Get the current offset to be able to apply it again (important for achieving the animation)
Scroll without animating to the currently calculated adjustedContentInset plus some more because the large title was not considered when calculating the adjustedContentInset
Now the system takes into account the large title so get the current adjustedContentInset that will include its size so store it to a constant that will be used in the last step
Scroll back to the original offset without animating so no visual changes will be noticed
Scroll to the previously calculated adjustedContentInset this time animating to achieve the desired animated scrolling
When !animated:
Scroll without animation to the adjustedContentInset plus some more. At this stage the system will consider the large title so...
Scroll to the current adjustedContentInset as it was calculated with the large title in it
Kind of a hack but does work.

Related

UICollectionView Compositional Layout - items size not updated properly on iPad rotation

I have a problem that I cannot resolve at the moment namely I'm trying to set a different number of columns in the collection view based on the iPad position (collection should update on device rotation)
Currently, (depending on how the device is positioned on start) on the first rotation items width is not calculated properly:
iPad screen
It'd be great if someone could help and point out where is an issue.
Code for this project is on my GitHub: https://github.com/ceboolion/compositionalLayout
Thanks for your help.
I gave your UICollectionView a background of orange and ran your code and got similar results:
I noticed few things
From this I could conclude the UICollectionView seems to be set up and working well on rotation, probably due to auto layout so I feel the issue is more to do with the layout code.
I also noticed when you start scrolling, the View fixes itself to what you want
Finally, I added one line of code to your func relayoutCollectionView(with size: CGSize) function to see what the dimensions are when you want update the layout calculations and columns
func relayoutCollectionView(with size: CGSize) {
collectionView.collectionViewLayout.shouldInvalidateLayout(forBoundsChange: CGRect(x: 0,
y: 0,
width: size.width,
height: size.height))
collectionView.setCollectionViewLayout(createFlowLayout(),
animated: true,
completion: nil)
collectionView.collectionViewLayout.invalidateLayout()
// I added this line
print("Collection View Width: \(collectionView.frame.size.width), Collection View Height: \(collectionView.frame.size.height)")
}
When I turned from portrait to landscape, the output printed was Collection View Width: 810.0, Collection View Height: 1080.0 which does not seem to be right.
So from this I assume that it is too early for invalidateLayout to be called to get the right calculations.
When I do something like this from a UIViewController, I will make invalidateLayout() call from viewWillLayoutSubviews()
In your case, what I tried and worked was adding the similar override func layoutSubviews() in your class CollectionView: UIView
override func layoutSubviews() {
super.layoutSubviews()
collectionView.collectionViewLayout.invalidateLayout()
}
I removed relayoutCollectionView from your func relayoutCollectionView
Now it seems to work for the first time as well although I can still not explain why your current works every other time except for the first time.
Just as top answer say, it will rotaion to wrong layout after scroll collection.
I try to set animated to false, it work!
But I can't explain why.
collectionView.setCollectionViewLayout(createFlowLayout(), animated: false, completion: nil)

How can I make an animated view be a consistent distance from the bottom of the screen on all devices?

I'm working on a feature that will animate a UIView up from the bottom of the screen to tell the user their data was saved. However, it doesn't appear in the same place on an iPhone 11 Pro Max as it does on an iPhone X.
iPhone 11 Pro Max (this is what I want it to look like):
but on an iPhone X:
I should probably note that I created the view on the storyboard and have an #IBOutlet connected to it. On the storyboard, it's positioned where you see it in the top screenshot. I did not place any constraints on it. The rest is done in code.
The code to animate is very simple:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
savedView.center.y += view.bounds.height // start with it off the screen
savedView.center.x = (view.bounds.width / 2)
}
override func viewDidAppear(_ animated: Bool) {
super.viewWillAppear(true)
// animate it onto the screen
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0.0, options: [], animations: {
self.savedView.center.y -= self.view.bounds.height <-- the problem must be here
}, completion: nil)
}
I thought it might be due to different device size classes but all iPhones are 'regular' height so I don't think that's it.
How can I get this view to end up in the same position (relative to the bottom of the screen) on all devices?
In order to place your views in the same place across all devices is a must to have constraints set up. So that, the system is able to show your views correctly.
So to achieve a bottom-centered attached behavior for your view, you need to set up a constraint to the safe-area bottom, a constraint called centered-horizontally, width and height, assuming that this view is the only component on screen. If it is not, then there must be a top constraint for the view with the component in the top of it.
Hope this helps, at least to give you an idea.
Constraints reference
I was able to figure it out.
The problem was that the view's initial center.y position was starting off at 814 based on its position in the storyboard. So it looked good on the storyboard which I'm viewing as an iPhone 11 Pro but, when run on other device simulators with different dimensions, the math didn't work anymore.
The solution was to set the center.y position based on the view.bounds.height giving it a consistent starting position on any device:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
savedView.center.y = (view.bounds.height - 60) // position the view 60 pixels bottom
savedView.center.x = (view.bounds.width / 2) // center it horizontally
savedView.center.y += view.bounds.height // move it outside the visible bounds
}

Image progress bar with an overlaid 0 to 100%

I am creating a custom progress bar and have ran into more issues. I have a progress bar that show an image going from full to nothing with a shadow that is red in the background. Basically this is a normal progress bar but with a custom image, I want to add a label or something like that on to that will go from 0 to 100% in the time it takes to animate. I tried adding counters to the animate but they do not increment like I want. This is my animation block so far.
override func viewDidAppear(_ animated: Bool) {
UIView.animate(withDuration: 3.0, animations: {
self.catScanE.frame = CGRect(x: 27 , y: 200, width: 320, height: 0)
}, completion: {finished in self.performSegue(withIdentifier: "lastSegue", sender: nil)})
}
Two things about your issue:
You need to set initial frame for catScanE before the animation.
The destination height of catScanE is 0. Did you mean to make it go from
large to small?

Showing a popover partially off screen

I am trying to recreate the effect that YouTube mobile app on iOS has, when a video is playing in full-screen, landscape mode. Just the top part of a window (next/recommended videos) is visible, and swiping up shows them overlapped with video (that keeps running in background). Swiping down hides them again.
So I added the following code within my video controller that shows the video in landscape mode:
private func showPopover() {
let popoverController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "popoverController")
popoverController.modalPresentationStyle = .popover
popoverController.popoverPresentationController?.permittedArrowDirections = UIPopoverArrowDirection(rawValue: 0)
popoverController.popoverPresentationController?.delegate = self
let desiredWidth:CGFloat = 200
let desiredHeight:CGFloat = 300
// Initially it's at low right corner
let origin = CGPoint(x: (self.view.superview?.frame.width)! - desiredWidth - 10, y: (self.view.superview?.frame.height)! - 10)
let size = CGSize(width: desiredWidth, height: desiredHeight)
popoverController.popoverPresentationController?.sourceRect = CGRect(origin: origin, size: size)
popoverController.popoverPresentationController?.sourceView = popoverController.view
self.present(popoverController, animated: true, completion: nil)
}
func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle {
return .none
}
The popover shows up fine, but is always completely on screen. I need it to be only partially visible (just the top 10 pixels, if possible). But something is preventing the view controller from being positioned that way.
How do I move the popover so that only top 10 pixels are visible? It should look something like in the image below (note: this was generating by editing image, not programmatically):
Replace this:
popoverController.popoverPresentationController?.sourceView = popoverController.view
with this:
popoverController.popoverPresentationController?.sourceView = self.view
UPDATE
Ok I understand now. My answer would be it is not possible to only show some part of the popover.
My suggestion here is using UITableViewController as your popover and making its height just enough to show the first row, which is the text in this case.
Then, in optional func scrollViewDidScroll(_ scrollView: UIScrollView) delegate method, implement logics to detect swiping up to show and swiping down to hide.
Found a way, and it's ridiculously easy (with hindsight, of course!). Changed the last line of showPopover function to:
self.present(popoverController, animated: true) {
popoverController.popoverPresentationController?.presentedView?.center = CGPoint(x: (self.view.superview?.frame.width)! - desiredWidth - 10, y: (self.view.superview?.frame.height)! + 100)
}
I will get a specific number instead of using 100 but the view moves to this position without any issues.

Get UIScrollView to scroll to the top

How do I make a UIScrollView scroll to the top?
UPDATE FOR iOS 7
[self.scrollView setContentOffset:
CGPointMake(0, -self.scrollView.contentInset.top) animated:YES];
ORIGINAL
[self.scrollView setContentOffset:CGPointZero animated:YES];
or if you want to preserve the horizontal scroll position and just reset the vertical position:
[self.scrollView setContentOffset:CGPointMake(self.scrollView.contentOffset.x, 0)
animated:YES];
Here is a Swift extension that makes it easy:
extension UIScrollView {
func scrollToTop() {
let desiredOffset = CGPoint(x: 0, y: -contentInset.top)
setContentOffset(desiredOffset, animated: true)
}
}
Usage:
myScrollView.scrollToTop()
For Swift 4
scrollView.setContentOffset(.zero, animated: true)
iOS 11 and above
Try to play around with the new adjustedContentInset (It should even work with prefersLargeTitles, safe area etc.)
For example (scroll to the top):
var offset = CGPoint(
x: -scrollView.contentInset.left,
y: -scrollView.contentInset.top)
if #available(iOS 11.0, *) {
offset = CGPoint(
x: -scrollView.adjustedContentInset.left,
y: -scrollView.adjustedContentInset.top)
}
scrollView.setContentOffset(offset, animated: true)
Use setContentOffset:animated:
[scrollView setContentOffset:CGPointZero animated:YES];
Answer for Swift 2.0/3.0/4.0 and iOS 7+:
let desiredOffset = CGPoint(x: 0, y: -self.scrollView.contentInset.top)
self.scrollView.setContentOffset(desiredOffset, animated: true)
In iOS7 I had trouble getting a particular scrollview to go to the top, which worked in iOS6, and used this to set the scrollview to go to the top.
[self.myScroller scrollRectToVisible:CGRectMake(0, 0, 1, 1) animated:NO];
In SWIFT 5
Just set content Offset to zero
scrollView.setContentOffset(CGPoint.zero, animated: true)
Swift 3.0.1 version of rob mayoff's answer :
self.scrollView.setContentOffset(
CGPoint(x: 0,y: -self.scrollView.contentInset.top),
animated: true)
I think I have an answer that should be fully compatible with iOS 11 as well as prior versions (for vertical scrolling)
This takes into account the new adjustedContentInset and also accounts for the additional offset required when prefersLargeTitles is enabled on the navigationBar which appears to require an extra 52px offset on top of whatever the default is
This was a little tricky because the adjustedContentInset changes depending on the titleBar state (large title vs small title) so I needed to check and see what the titleBar height was and not apply the 52px offset if its already in the large state. Couldn't find any other method to check the state of the navigationBar so if anyone has a better option than seeing if the height is > 44.0 I'd like to hear it
func scrollToTop(_ scrollView: UIScrollView, animated: Bool = true) {
if #available(iOS 11.0, *) {
let expandedBar = (navigationController?.navigationBar.frame.height ?? 64.0 > 44.0)
let largeTitles = (navigationController?.navigationBar.prefersLargeTitles) ?? false
let offset: CGFloat = (largeTitles && !expandedBar) ? 52: 0
scrollView.setContentOffset(CGPoint(x: 0, y: -(scrollView.adjustedContentInset.top + offset)), animated: animated)
} else {
scrollView.setContentOffset(CGPoint(x: 0, y: -scrollView.contentInset.top), animated: animated)
}
}
Inspired by Jakub's solution
It's very common when your navigation bar overlaps the small portion of the scrollView content and it looks like content starts not from the top. For fixing it I did 2 things:
Size Inspector - Scroll View - Content Insets --> Change from Automatic to Never.
Size Inspector - Constraints- "Align Top to" (Top Alignment Constraints)- Second item --> Change from Superview.Top to Safe Area.Top and the value(constant field) set to 0
To fully replicate the status bar scrollToTop behavior we not only have to set the contentOffset but also want to make sure the scrollIndicators are displayed. Otherwise the user can quickly get lost.
The only public method to accomplish this is flashScrollIndicators. Unfortunately, calling it once after setting the contentOffset has no effect because it's reset immediately. I found it works when doing the flash each time in scrollViewDidScroll:.
// define arbitrary tag number in a global constants or in the .pch file
#define SCROLLVIEW_IS_SCROLLING_TO_TOP_TAG 19291
- (void)scrollContentToTop {
[self.scrollView setContentOffset:CGPointMake(self.scrollView.contentOffset.x, -self.scrollView.contentInset.top) animated:YES];
self.scrollView.tag = SCROLLVIEW_IS_SCROLLING_TO_TOP_TAG;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
self.scrollView.tag = 0;
});
}
In your UIScrollViewDelegate (or UITable/UICollectionViewDelegate) implement this:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (scrollView.tag == SCROLLVIEW_IS_SCROLLING_TO_TOP_TAG) {
[scrollView flashScrollIndicators];
}
}
The hide delay is a bit shorter compared to the status bar scrollToTop behavior but it still looks nice.
Note that I'm abusing the view tag to communicate the "isScrollingToTop" state because I need this across view controllers. If you're using tags for something else you might want to replace this with an iVar or a property.
In modern iOS, set the the scroll view's content offset back to its top left adjustedContentInset:
let point = CGPoint(x: -scrollView.adjustedContentInset.left,
y: -scrollView.adjustedContentInset.top)
scrollView.setContentOffset(point, animated: true)
Scroll to top for UITableViewController, UICollectionViewController or any UIViewController having UIScrollView
extension UIViewController {
func scrollToTop(animated: Bool) {
if let tv = self as? UITableViewController {
tv.tableView.setContentOffset(CGPoint.zero, animated: animated)
} else if let cv = self as? UICollectionViewController{
cv.collectionView?.setContentOffset(CGPoint.zero, animated: animated)
} else {
for v in view.subviews {
if let sv = v as? UIScrollView {
sv.setContentOffset(CGPoint.zero, animated: animated)
}
}
}
}
}
iOS 16
For table and collection views, the following always works for me:
let top = CGRect(x: 0, y: 0, width: 1, height: 1)
tableView.scrollRectToVisible(top, animated: true)
collectionView.scrollRectToVisible(top, animated: true)
For scroll views:
let top = CGPoint(x: 0, y: -adjustedContentInset.top)
scrollView.setContentOffset(top, animated: animated)
adjustedContentInset returns the insets applied by the safe area (if any) and any custom insets applied after instantiation. If either safe or custom insets are applied, the content inset of the scroll view when it's at its top will be negative, not zero, which is why this property should be used.
iOS 2.0+
Mac Catalyst 13.0+
You can try: scrollView.scrollsToTop = true
You can refer it from documentation of developer.apple.com
I tried all the ways. But nothing worked for me. Finally I did like this.
I added self.view .addSubview(self.scroll) line of code in the viewDidLoad. After started setting up frame for scroll view and added components to scroll view.
It worked for me.
Make sure you added self.view .addSubview(self.scroll) line in the beginning. then you can add UI elements.

Resources