Scroll to the bottom of textView programmatically - ios

I made a button that adds some text to a textView and I want it to automatically scroll to the bottom as it is pressed so that user would be able to see the new text added.
I can't use this solution in Swift because I don't know Objective-C.
Does anyone know how can I scroll to the bottom of a textView in Swift? Thanks.

Tried both content offset and scrolltoview solutions, get mixed results and choppy scrolling; having looked around the below seemed to work and produce consistent scrolling to the bottom when needed.
In viewdidload:
self.storyTextView.layoutManager.allowsNonContiguousLayout = false
Then when needed:
let stringLength:Int = self.storyTextView.text.characters.count
self.storyTextView.scrollRangeToVisible(NSMakeRange(stringLength-1, 0))

Swift 4
let bottom = NSMakeRange(textLog.text.count - 1, 1)
textLog.scrollRangeToVisible(bottom)
Swift 3
let bottom = NSMakeRange(textLog.text.characters.count - 1, 1)
textLog.scrollRangeToVisible(bottom)
Update: thanks #AntoineRucquoy for Swift 4 reminder!

Simply, where myTextView is the UITextView in question:
let bottom = myTextView.contentSize.height
myTextView.setContentOffset(CGPoint(x: 0, y: bottom), animated: true) // Scrolls to end

So if you click the link you posted the accepted answer shows this objective-C code:
-(void)scrollTextViewToBottom:(UITextView *)textView
{
if(textView.text.length > 0 )
{
NSRange bottom = NSMakeRange(textView.text.length -1, 1);
[textView scrollRangeToVisible:bottom];
}
}
So your challenge is to convert that code to Swift.
Break it into pieces and tackle them one at a time. First, the method definition itself.
The method is called scrollTextViewToBottom. It takes a UITextView as a parameter, and does not return a result. How would you write that method definition in Swift?
Next look that the body of the method. The if statement should be exactly the same in Swift.
The creation of an NSRange is all but identical. You just need to change it a little bit:
let bottom = NSMakeRange(textView.text.length -1, 1)
The part that's probably the hardest for somebody who doesn't know Objective-C is the method call. It's sending the message scrollRangeToVisible to the object textView. The parameter passed is bottom. See if you can rewrite that line in Swift. Then put the whole thing together.

I use the following in an app that scrolls to the bottom automatically when text is added:
First when initializing your textView, do the following:
textView.layoutManager.allowsNonContiguousLayout = false
textView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
Then add the following observer method:
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
var bottom = textView.contentSize.height - textView.frame.size.height
if bottom < 0 {
bottom = 0
}
if textView.contentOffset.y != bottom {
textView.setContentOffset(CGPoint(x: 0, y: bottom), animated: true)
}
}
setting allowsNonContiguousLayout to false fixed contentSize problems for me.
Adding the contentSize observer will observe for any new changes in the contentSize of the textView and call the -observeValue(forKeyPath...) function when changes are made.
In the -observeValue(...) function, we first get the bottom (y contentOffset when fully scrolled to the bottom). We then check if that value is negative, meaning that the contentSize height is smaller than the textView frame height and you can't really do any scrolling. If you try to programmatically scroll with that negative value, it will cause that infamous jitter that many people know and love. So to avoid this jitter we simply set the value to what it already should be, 0 or you can also just return.
Then we just test to see if the contentOffset doesn't already equal the bottom value, we give it that new value. This avoids setting the contentOffset when it doesn't need to be set.

Language:Swift
Follow steps as below:
//Declare
#IBOutlet weak var trTextDataRead: UITextView!
//Cunstom method
func insertTextView(text: String){
//Insert text
trTextDataRead.text.append(text)
//Scroll to the end
let btm = NSMakeRange(trTextDataRead.text.lengthOfBytes(using: String.Encoding.utf8), 0)
trTextDataRead.scrollRangeToVisible(btm)
}

If you're dealing with the UITextView's attributedText property:
in viewDidLoad()
self.storyTextView.layoutManager.allowsNonContiguousLayout = false
in your scrolling method
let stringLength:Int = self.storyTextView.attributedText.string.characters.count
self.storyTextView.scrollRangeToVisible(NSMakeRange(stringLength-1, 0))

Swift 4
private func textViewScrollToBottom() {
let bottomRange = NSMakeRange(self.myTextView.text.count - 1, 1)
self.myTextView.scrollRangeToVisible(bottomRange)
}

UITextView has a property contentOffsent. You can either set textView.contentOffset or textView.setContentOffset(offset, animated: true)
For example if the contentSize of your text view is (100, 500) but the height of the text view is only 100, then to scroll to the bottom, set the contentOffset property to (0, 400) (this is for a vertical text view). More generically the formula for scrolling to the bottom is textView.contentSize.height-textView.height. Every time your button is pressed, set the offset.
I would also really recommend reading the documentation and trying to figure it out. Swift and iOS is quite well documented and a question like this is easily searchable via Google.
Edit: This works because UITextView inherits from UIScrollView.
Sidenote: I wrote a UITextView subclass where you can set the vertical text alignment so if you set the text alignment to .Bottom, the text will align with the bottom of the view.
class TextView: UITextView {
enum VerticalAlignment: Int {
case Top = 0, Middle, Bottom
}
var verticalAlignment: VerticalAlignment = .Middle
//override contentSize property and observe using didSet
override var contentSize: CGSize {
didSet {
let textView = self
let height = textView.bounds.size.height
let contentHeight:CGFloat = contentSize.height
var topCorrect: CGFloat = 0.0
switch(self.verticalAlignment){
case .Top:
textView.contentOffset = CGPointZero //set content offset to top
case .Middle:
topCorrect = (height - contentHeight * textView.zoomScale)/2.0
topCorrect = topCorrect < 0 ? 0 : topCorrect
textView.contentOffset = CGPoint(x: 0, y: -topCorrect)
case .Bottom:
topCorrect = textView.bounds.size.height - contentHeight
topCorrect = topCorrect < 0 ? 0 : topCorrect
textView.contentOffset = CGPoint(x: 0, y: -topCorrect)
}
if contentHeight >= height { //if the contentSize is greater than the height
topCorrect = contentHeight - height //set the contentOffset to be the
topCorrect = topCorrect < 0 ? 0 : topCorrect //contentHeight - height of textView
textView.contentOffset = CGPoint(x: 0, y: topCorrect)
}
}
}
// MARK: - UIView
override func layoutSubviews() {
super.layoutSubviews()
let size = self.contentSize //forces didSet to be called
self.contentSize = size
}
}
In the above example (pulled directly from my subclass), you'll notice I make extensive use of the contentOffset property. I do some calculations to figure out where the offset should be based on the vertical alignment property and then set the content offset property according (which is how you programmatically scroll with a scroll view)

A lot of people are explaining how to scroll to the bottom, but one thing to note is that this won't work if you place it in viewDidLoad. For example: I needed to use this to scroll a log to the bottom when the page loaded. In order to do this, I had to implement the following code
- (void) viewDidLoad {
[super viewDidLoad];
[_logTextView setText:[Logger loadLogText]];
}
- (void) viewDidLayoutSubviews {
[_logTextView
setContentOffset:CGPointMake(
0.0,
_logTextView.contentSize.height
- _logTextView.frame.size.height
)
animated:NO
];
}
The actual scrolling of the UITextView cannot be done in viewDidLoad.
In my viewDidLoad implementation I set the text for the text box.
In my viewDidLayoutSubviews implementation I set the content offset for the UITextView by generating a CGPoint using the height of the text views content minus the height of the text view itself. This way, when it scrolls to the bottom, the bottom of the text is not at the top of the box and instead, the bottom of the text is at the bottom of the box.

Swift 5 - with extension to UITextView + avoid slow scroll in large texts (this issue killed my main Thread)
extension UITextView {
func scrollToBottom() {
// IMPORTANT - only use (text as NSString) to get the length, since text.length is O(N) and it will kill the main thread.
let length = (text as NSString).length
if length > 1 {
scrollRangeToVisible(NSMakeRange(length - 1, 1))
}
}
}

Related

Self-sizing cells and dynamic size controls for iOS

Problem definition
I am trying to build a custom control which will behave similarly to UILabel. I should be able to place such a control inside of a self-sizing table cell and it should:
Wrap it's content (like UILabel with numberOfLines=0 does)
automatically extend self-sized cell height size
handle a device rotation
don't require any special code in UITableCellView or ViewControll to implement this functionality (UILabel doesn't require any special code for that).
Research
The first thing which I did is very simple. I decided to observe how UILabel works. I did following:
created a table with self-sizing cells
created a custom cell, put UILabel (with numberOfLines=0) in it
created constraints to make sure that UILabel occupies a whole cell
subclasses UILabel and overrode a bunch of methods to see how it behaves
I checked following things
Run it in a portrait (the label is displayed correctly over several lines) and the cell height is correct
Rotate it. The table width and height was updated and they are correct too.
I observed that it behaves well. It doesn't require any special code and I saw the order of (some) calls which system does to render it.
A partial solution
#Wingzero wrote a partial solution below. It creates cells of a correct size.
However, his solution has two problems:
It uses "self.superview.bounds.size.width". This could be used if your control occupies a whole cell. However, if you have anything else in the cell which uses constraints then such code won't calculate a width correctly.
It doesn't handle rotation at all. I am pretty sure it doesn't handle other resizing events (there are bunch of less common resizing events - like a statusbar getting bigger on a phone call etc).
Do you know how to solve these problems for this case?
I found a bunch of articles which talks about building more static custom controls and using pre-built controls in self-sizing cells.
However, I haven't found anything which put together a solution to handle both of these.
I have to use the answer section to post my ideas and moving forward, though it may not be your answer, since I am not fully understand what's blocking you, because I think you already know the intrinsic size and that's it.
based on the comments, I tried to create a view with a text property and override the intrinsic:
header file, later I found maxPreferredWidth is not used totally, so ignore it:
#interface LabelView : UIView
IB_DESIGNABLE
#property (nonatomic, copy) IBInspectable NSString *text;
#property (nonatomic, assign) IBInspectable CGFloat maxPreferredWidth;
#end
.m file:
#import "LabelView.h"
#implementation LabelView
-(void)setText:(NSString *)text {
if (![_text isEqualToString:text]) {
_text = text;
[self invalidateIntrinsicContentSize];
}
}
-(CGSize)intrinsicContentSize {
CGRect boundingRect = [self.text boundingRectWithSize:CGSizeMake(self.superview.bounds.size.width, CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading
attributes:#{NSFontAttributeName:[UIFont systemFontOfSize:16]}
context:nil];
return boundingRect.size;
}
#end
and a UITableViewCell with xib:
header file:
#interface LabelCell : UITableViewCell
#property (weak, nonatomic) IBOutlet LabelView *labelView;
#end
.m file:
#implementation LabelCell
- (void)awakeFromNib {
[super awakeFromNib];
}
#end
xib, it's simple, just top, bottom, leading, trailing constraints:
So running it, based on the text's bounding rect, the cell's height is different, in my case, I have two text to loop: 1. "haha", 2. "asdf"{repeat many times to create a long string}
so the odd cell is 19 height and even cell is 58 height:
Is this what are you looking for?
My ideas:
the UITableView's cell's width is always the same as the table view, so that's the width. UICollectionView may be more issues there, but the point is we will calculate it and just return it is enough.
Demo project: https://github.com/liuxuan30/StackOverflow-DynamicSize
(I changed based on my old project, which has some images, ignore those.)
Here's a solution that meets your requirements and is also IBDesignable so it previews live in Interface Builder. This class will lay out a series of squares (the total number is equal to the IBInspectable count property). By default, it will just lay them all out in one long line. But if you set the wrap IBInspectable property to On, it will wrap the squares and increase its height based on its constrained width (like a UILabel with numberOfLines == 0). In a self-sizing table view cell, this will have the effect of pushing out the top and bottom to accommodate the wrapped intrinsic size of the custom view.
The code:
import Foundation
import UIKit
#IBDesignable class WrappingView : UIView {
private class InnerWrappingView : UIView {
private var lastPoint:CGPoint = CGPointZero
private var wrap = false
private var count:Int = 100
private var size:Int = 8
private var spacing:Int = 3
private func calculatedSize() -> CGSize {
lastPoint = CGPoint(x:-(size + spacing), y: 0)
for _ in 0..<count {
var nextPoint:CGPoint!
if wrap {
nextPoint = lastPoint.x + CGFloat(size + spacing + size) <= bounds.width ? CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y) : CGPoint(x: 0, y: lastPoint.y + CGFloat(size + spacing))
} else {
nextPoint = CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y)
}
lastPoint = nextPoint
}
return CGSize(width: wrap ? bounds.width : lastPoint.x + CGFloat(size), height: lastPoint.y + CGFloat(size))
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.size != calculatedSize() || subviews.count == 0 else {
return
}
for subview in subviews {
subview.removeFromSuperview()
}
lastPoint = CGPoint(x:-(size + spacing), y: 0)
for _ in 0..<count {
let square = createSquareView()
var nextPoint:CGPoint!
if wrap {
nextPoint = lastPoint.x + CGFloat(size + spacing + size) <= bounds.width ? CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y) : CGPoint(x: 0, y: lastPoint.y + CGFloat(size + spacing))
} else {
nextPoint = CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y)
}
square.frame = CGRect(origin: nextPoint, size: square.bounds.size)
addSubview(square)
lastPoint = nextPoint
}
let newframe = CGRect(origin: frame.origin, size: calculatedSize())
frame = newframe
invalidateIntrinsicContentSize()
setNeedsLayout()
}
private func createSquareView() -> UIView {
let square = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size))
square.backgroundColor = UIColor.blueColor()
return square
}
override func intrinsicContentSize() -> CGSize {
return calculatedSize()
}
}
#IBInspectable var count:Int = 500 {
didSet {
innerView.count = count
layoutSubviews()
}
}
#IBInspectable var size:Int = 8 {
didSet {
innerView.size = size
layoutSubviews()
}
}
#IBInspectable var spacing:Int = 3 {
didSet {
innerView.spacing = spacing
layoutSubviews()
}
}
#IBInspectable var wrap:Bool = false {
didSet {
innerView.wrap = wrap
layoutSubviews()
}
}
private var _innerView:InnerWrappingView! {
didSet {
clipsToBounds = true
addSubview(_innerView)
_innerView.clipsToBounds = true
_innerView.frame = bounds
_innerView.wrap = wrap
_innerView.translatesAutoresizingMaskIntoConstraints = false
_innerView.leftAnchor.constraintEqualToAnchor(leftAnchor).active = true
_innerView.rightAnchor.constraintEqualToAnchor(rightAnchor).active = true
_innerView.topAnchor.constraintEqualToAnchor(topAnchor).active = true
_innerView.bottomAnchor.constraintEqualToAnchor(bottomAnchor).active = true
_innerView.setContentCompressionResistancePriority(750, forAxis: .Vertical)
_innerView.setContentHuggingPriority(251, forAxis: .Vertical)
}
}
private var innerView:InnerWrappingView! {
if _innerView == nil {
_innerView = InnerWrappingView()
}
return _innerView
}
override func layoutSubviews() {
super.layoutSubviews()
if innerView.bounds.width != bounds.width {
innerView.frame = CGRect(origin: CGPointZero, size: CGSize(width: bounds.width, height: 0))
}
innerView.layoutSubviews()
if innerView.bounds.height != bounds.height {
invalidateIntrinsicContentSize()
superview?.layoutIfNeeded()
}
}
override func intrinsicContentSize() -> CGSize {
return innerView.calculatedSize()
}
}
In my sample application, I set the table view to dequeue a cell containing this custom view for each row, and set the count property of the custom view to 20 * the indexPath's row. The custom view is constrained to 50% of the cell's width, so its width will change automatically when moving between landscape and portrait. Because each successive table cell wraps a longer and longer string of squares, each cell is automatically sized to be taller and taller.
When running, it looks like this (includes demonstration of rotation):
To build on the other answer from #Wingzero, layout is a complex problem...
The maxPreferredWidth mentioned is important, and relates to preferredMaxLayoutWidth of UILabel. The point of this attribute is to tell a label not to just be one long line and to instead prefer to wrap if the width gets to that value. So when calculating the intrinsic size you would use the minimum of the preferredMaxLayoutWidth (if set) or the view width as the max width.
Another key aspect is invalidateIntrinsicContentSize which the view should call on itself whenever something changes and means a new layout is required.
UILabel doesn't handle rotation - it doesn't know about it. It's the responsibility of the view controller to detect and handle rotation, generally by invalidating the layout and updating the view size before triggering a new layout run. The labels (and other views) are just there to handle the resulting layout. As part of the rotation you (i.e. a view controller) may change the preferredMaxLayoutWidth as it makes sense to allow more width in landscape layout for example.
Are you looking for something like this ^^? The cell has dynamic heights to facilitate the content of the UILabel, and there's no code to calculate size/width/height whatsoever - just some constraints.
Essentially, the label at left-hand side has top, bottom and leading margin to the cell, and trailing margin to the right-hand side label, which has trailing margin to the cell. Just need one label? Ignore the right hand side label then, and configure the left hand side label with a trailing constraint to the cell.
And if you need the label to be multi-line, configure that. Set the numberOfLines to 2, 3, or 0, up to you.
You don't need to calculate table view cell's height, the auto layout will calculate for you; but you need to let it know that, by telling it to use auto dimension: self.tableView.rowHeight = UITableViewAutomaticDimension, or return it in tableView:heightForRowAtIndexPath:. And you can also tell table view a "rough" estimation in tableView:estimatedHeightForRowAtIndexPath: for a better performance.
Still not working? Set the Content Compression Resistance Priority - Vertical to 1000 / Required for the UILabel in question, so that the label's content will try its best to "resist the compression", and the numberOfLines configuration will be fully acknowledged.
And it rotates? Try to observe the rotation (there're orientation change notifications) and then update layout (setNeedsLayout).
Still not working? More reads here: Using Auto Layout in UITableView for dynamic cell layouts & variable row heights

Magnify UITableViewCell as it scrolls closer to center of screen

I've been working at this for a good 48 hours now and can't seem to solve it on my own. What I'm trying to achieve is when a UITableViewCell is scrolled from the bottom to the top of the screen it starts small, magnifies as it reaches the center portion of the screen, and then begins to reduce in size again as it scrolls up and off of the screen (and vice versa, top to bottom). I can kind of get this to work, but it seems as though the contentView is resizing, the actual cell height (of the UITableView) is not.
What I want:
Scrolling up or down smoothly adjusts the size of all the uitableviewcells based on its y position on the screen.
What appears to be happening (though I'm having trouble confirming):
Again, it's as if the cell height is not being adjusted, but the height of the contentView is.
I can achieve this by overriding the layoutVisibleCells method of the UITableView:
func layoutVisibleCells() {
let indexpaths = indexPathsForVisibleRows!
let totalVisibleCells = indexpaths.count - 1
if totalVisibleCells <= 0 { return }
for index in 0...totalVisibleCells {
let indexPath = indexpaths[index]
if let cell = cellForRowAtIndexPath(indexPath) {
var frame = cell.frame
if let superView = superview {
let point = convertPoint(frame.origin, toView:superView)
let pointScale = point.y / CGFloat(superView.bounds.size.height)
var height = frame.size.height + point.x;
if height < 150.0 {
height = 150.0;
}
if height > 200.0 {
height = 200.0;
}
frame.size.height = height;
NSLog("index %li: x: %f", index,frame.origin.x);
NSLog("point at index %li: %f, %f - percentage: %f", index,point.x,point.y, pointScale);
}
cell.frame = frame
}
}
}
I took the idea for making the adjustment in this method from COBezierTableView, which is written in Swift. The rest of my project is in Obj-C, so deciphering things was a bit challenging, as I'm still learning my way there.
Any insight on my current attempt, or suggestions for how to achieve this in another, completely different way, are totally welcome. Thanks in advance.
UPDATE
So I've actually found that the opposite is happening of what I suspected. The UITableViewCell frame IS being resized correctly. It's the cell's contentView that is not resizing fully. It does resize partially, but for some reason it does not resize to match the cell's frame. Not sure why ...

How do I make a UIImage fade in and fade out as I scroll?

I have a stretchy header that I made following this tutorial http://blog.matthewcheok.com/design-teardown-stretchy-headers/. Anyway it's working perfectly but I'm having trouble making a UIView on top of it fade out as the view stretched and returning to original alpha as the view is returned to normal. Best I could come up with is this:
override func
scrollViewDidScroll(scrollView: UIScrollView) {
updateHeaderView()
var offset = scrollView.contentOffset.y
if offset < -170 {
headerBlurImageView?.alpha = max(0.0, offset - 95/35)
} else {
self.headerBlurImageView?.alpha = 1
}
}
But it barely works. There is no smooth transition between the alphas and when the view is returned to normal the alpha doesn't return. Any advice or hints?
Update: I managed to do the exact opposite of what I wanted :p Here's the code:
override func scrollViewDidScroll(scrollView: UIScrollView) {
updateHeaderView()
var height: CGFloat
var position: CGFloat
var percent: CGFloat
height = scrollView.bounds.size.height/2
position = max(-scrollView.contentOffset.y, 0.0)
percent = min(position / height, 1.0)
self.headerBlurImageView.alpha = percent
}
Take a look at the UIScrollViewDelegate protocol. there are a number of messages that relate to starting and ending dragging, and decelerating. You should be able to set your view controller up as the scroll view's delegate and implement some of those methods. I'd create a UIView animation that animates the header's alpha down when scrolling begins, and another UIView animation that animates it back to opaque once scrolling ends (or possibly once deceleration ends.)
Layout two UIImageViews on top of one another (in this example, bg is under bgB), then layout the UIScrollview at the very top. In the scrollviewDidScroll method, place the following code:
float off = cv.contentOffset.x;
float floatedIndex = off/cv.frame.size.width;
int left = floatedIndex;
int right = ceil(floatedIndex);
if (right>events.count-1){ right=events.count-1; }
float alpha = floatedIndex-left;
UIImage * leftImage = nil;
UIImage * rightImage = nil;
NSDictionary * ld = events[left];
NSDictionary * rd = events[right];
NSObject * o = ld[#"artwork"];
if ([o isKindOfClass:[UIImage class]]){
leftImage = ld[#"artwork"];
}
o = rd[#"artwork"];
if ([o isKindOfClass:[UIImage class]]){
rightImage = rd[#"artwork"];
}
bg.image = leftImage;
bgB.image = rightImage;
bgB.alpha = alpha;
The 'left' and 'right' ints correspond to indices in an array. There's a line checking that the scrollview cannot accidently go 'too far right' ie trying to find an index outside the bounds of the array.
Then I'm pulling data from an array called events, and checking for the existence of an image in the resulting dictionary (but you could use it for a straight up array of images), updating both UIImageviews and then fading the one on top (bgB) in and out depending on the scroll's content offset.
The 'animation' correlates with user-interaction, which is great, but sometimes it's better to use animation blocks with another scrollview delegate method.

How to detect scrolling to a new section in a UICollectionView?

I am implementing an infinite-scrolling calendar. My issue is that I would like to set the current month as the title in the navigation bar and it should update while scrolling - once you pass the section header view the title should update in the nav bar.
A possible solution would be to set the view title in the method called - (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath so that, when I calculate a new section Header, it also updates the title. The problem with this is that the title changes when the new section is at the bottom of the page.
Is there a way to know the "current section" of UICollectionView once the user has scrolled to it? Or can you think of a way to improve my current solution?
To help the readers of this post, I posted my own sample code for this question at this GitHub repo.
I have been pondering an algorithm that would allow you to know when the user has scrolled past a section header in order to update the title, and after some experimentation I have figured out how to implement the desired behavior.
Essentially, every time the scroll position changes you need to know what section the user is on and update the title. You do this via scrollViewDidScroll on the UIScrollViewDelegate - remembering a collection view is a scroll view. Loop over all the headers and find the one that's closest to the current scroll position, without having a negative offset. To do that, I utilized a property that stores an array of each section header's position. When a header is created, I store its position in the array at the appropriate index. Once you've found the header that's closest to your scroll position (or the index location of said header), simply update the title in the navigation bar with the appropriate title.
In viewDidLoad, fill the array property with NSNull for each section you have:
self.sectionHeaderPositions = [[NSMutableArray alloc] init];
for (int x = 0; x < self.sectionTitles.count; x++) {
[self.sectionHeaderPositions addObject:[NSNull null]];
}
In collectionView:viewForSupplementaryElementOfKind:atIndexPath:, update the array with the position of the created header view:
NSNumber *position = [NSNumber numberWithFloat:headerView.frame.origin.y + headerView.frame.size.height];
[self.sectionHeaderPositions replaceObjectAtIndex:indexPath.section withObject:position];
In scrollViewDidScroll:, perform the calculations to determine which title is appropriate to display for that scroll position:
CGFloat currentScrollPosition = self.collectionView.contentOffset.y + self.collectionView.contentInset.top;
CGFloat smallestPositiveHeaderDifference = CGFLOAT_MAX;
int indexOfClosestHeader = NSNotFound;
//find the closest header to current scroll position (excluding headers that haven't been reached yet)
int index = 0;
for (NSNumber *position in self.sectionHeaderPositions) {
if (![position isEqual:[NSNull null]]) {
CGFloat floatPosition = position.floatValue;
CGFloat differenceBetweenScrollPositionAndHeaderPosition = currentScrollPosition - floatPosition;
if (differenceBetweenScrollPositionAndHeaderPosition >= 0 && differenceBetweenScrollPositionAndHeaderPosition <= smallestPositiveHeaderDifference) {
smallestPositiveHeaderDifference = differenceBetweenScrollPositionAndHeaderPosition;
indexOfClosestHeader = index;
}
}
index++;
}
if (indexOfClosestHeader != NSNotFound) {
self.currentTitle.text = self.sectionTitles[indexOfClosestHeader];
} else {
self.currentTitle.text = self.sectionTitles[0];
}
This will correctly update the title in the nav bar once the user scrolls past the header for a section. If they scroll back up it will update correctly as well. It also correctly sets the title when they haven't scrolled past the first section. It however doesn't handle rotation very well. It also won't work well if you have dynamic content, which may cause the stored positions of the header views to be incorrect. And if you support jumping to a specific section, the user jumps to a section whose previous section's section header hasn't been created yet, and that section isn't tall enough such that the section header is underneath the nav bar (the last section perhaps), the incorrect title will be displayed in the nav bar.
If anyone can improve upon this to make it more efficient or otherwise better please do and I'll update the answer accordingly.
change below line in method viewForSupplementaryElementOfKind :
self.title = [df stringFromDate:[self dateForFirstDayInSection:indexPath.section]];
to this:
if(![[self dateForFirstDayInSection:indexPath.section-1] isKindOfClass:[NSNull class]]){
self.title = [df stringFromDate:[self dateForFirstDayInSection:indexPath.section-1]];
}
Hope it will help you.
Yes, the problem is that footer and header are not exists in visibleCells collection. There is other way to detect scroll for section header/footer. Just add a control there and find the rect for it. Like this:
func scrollViewDidScroll(scrollView: UIScrollView) {
if(footerButton.tag == 301)
{
let frame : CGRect = footerButton.convertRect(footerButton.frame, fromView: self.view)
//some process for frame
}
}
No solution here is fulfilling, so I came up with my own that I want to share, fully. If you use this, you have to make some pixel adjustments, though.
extension MyViewControllerVC: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == self.myCollectionView {
let rect = CGRect(origin: self.myCollectionView.contentOffset, size: self.cvProductItems.bounds.size)
let cellOffsetX: CGFloat = 35 // adjust this
let cellOffsetAheadY: CGFloat = 45 // adjust this
let cellOffsetBehindY: CGFloat = 30 // adjust this
var point: CGPoint = CGPoint(x: rect.minX + cellOffsetX, y: rect.minY + cellOffsetAheadY) // position of cell that is ahead
var indexPath = self.myCollectionView.indexPathForItem(at: point)
if indexPath?.section != nil { // reached next section
// do something with your section (indexPath!.section)
} else {
point = CGPoint(x: rect.minX + cellOffsetX, y: rect.minY - cellOffsetBehindY) // position of cell that is behind
indexPath = self.myCollectionView.indexPathForItem(at: point)
if indexPath?.section != nil { // reached previous section
// do something with your section (indexPath!.section)
}
}
}
}
}
UICollectionView inherits UIScrollView, so we can just do self.myCollectionView.delegate = self in viewDidLoad() and implement the UIScrollViewDelegate for it.
In the scrollViewDidScroll callback we will first get the point of a cell below, adjust cellOffsetX and cellOffsetAheadY properly, so your section will be selected when the cell hits that point. You can also modify the CGPoint to get a different point from the visible rect, i.e for x you can also use rect.midX / rect.maxX and any custom offset.
An indexPath will be returned from indexPathForItem(at: GCPoint) when you hit a the cell with those coordinates.
When you scroll up, you might want to look ahead, possibly ahead your UICollectionReusableView header and footer, for this I also check the point with negative Y offset set in cellOffsetBehindY. This has lower priority.
So, this example will get the next section once you pass the header and the previous section once a cell of the previous section is about to get into view. You have to adjust it to fit your needs and you should store the value somewhere and only do your thing when then current section changes, because this callback will be called on every frame while scrolling.

Parallax effect with UIScrollView subviews

I'm trying to create a Parallax effect on a UIView inside a UIScrollView.
The effect seems to work, but not so well.
First i add two UIView sub-views to a UIScrollView and set the UIScrollViews contentSize.
The Views sum up and create a contentSize of {320, 1000}.
Then I implemented the following in scrollViewDidScroll:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGFloat offsetY = scrollView.contentOffset.y;
CGFloat percentage = offsetY / scrollView.contentSize.height;
NSLog(#"percent = %f", percentage);
if (offsetY < 0) {
firstView.center = CGPointMake(firstView.center.x, firstView.center.y - percentage * 10);
} else if (offsetY > 0){
firstView.center = CGPointMake(firstView.center.x, firstView.center.y + percentage * 10);
}
}
These lines of code do create a parallax effect, but as the scrolling continues, the view does not return to it's original position if i scroll to the original starting position.
I have tried manipulating the views layers and frame, all with the same results.
Any Help will be much appreciated.
The problem you have is that you are basing your secondary scrolling on a ratio of offset to size, not just on the current offset. So when you increase from an offset of 99 to 100 (out of say 100) your secondary scroll increases by 10, but when you go back down to 99 your secondary scroll only decreases by 9.9, and is thereby no longer in the same spot as it was last time you were at 99. Non-linear scrolling is possible, but not the way you are doing it.
A possible easier way to deal with this is to create a second scrollview and place it below your actual scrollview. Make it non intractable (setUserInteractionEnabled:false) and modify it's contentOffset during the main scrolling delegate instead of trying to move a UIImageView manually.
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
[scrollView2 setContentOffset:CGPointMake(scrollView.contentOffset.x,scrollView.contentOffset.y * someScalingFactor) animated:NO];
}
But make sure not to set a delegate for the scrollView2, otherwise you may get a circular delegate method call that will not end well for you.
Scaling Factor being the key element...
...let me offer a 1:1 calculation:
Assuming 2 UIScrollView, one in the foreground and on in the rear, assuming the foreground controls the rear, and further assuming that a full width in the foreground corresponds to a full width in the background, you then need to apply the fore ratio, not the fore offset.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let foreSpan = foreScrolView.bounds.width - foreScrolView.contentSize.width
let foreRatio = scrollView.contentOffset.x / foreSpan
let rearSpan = rearScrollView.bounds.width - rearScrollView.contentSize.width
rearScrollView.setContentOffset(
CGPoint(x: foreRatio * rearSpan, y: 0),
animated: false)
}
Final effect
The two scrollers, fore and rear, each contain a UIImageView displayed at its full width:
let foreImg = UIImageView.init(image: UIImage(named: "fore"))
foreImg.frame = CGRect(x: 0, y: 0,
width: foreImg.frame.width,
height: foreScrolView.bounds.height)
foreScrolView.contentSize = foreImg.frame.size
foreScrolView.addSubview(foreImg)
let rearImg = UIImageView.init(image: UIImage(named: "rear"))
rearImg.frame = CGRect(x: 0, y: 0,
width: rearImg.frame.width,
height: rearScrollView.bounds.height)
rearScrollView.contentSize = rearImg.frame.size
rearScrollView.addSubview(rearImg)
This will scroll both images at a different speed, covering each image in full from edge to edge.
► Find this solution on GitHub and additional details on Swift Recipes.

Resources