3

I am trying to build a scroll view with a "floating" header using auto-layout. To be more exact I am trying to build a calendar view with several columns. Each of those column should have its own header which should float on top while the column can be scrolled vertically below it.

If everything works it looks like this: Calendar display working correctly

As you can see there are several columns and a page control to indicate how many more pages of columns are available.

However when swiping/panning (or even just trying to swipe) to switch to the next page, the constraints keeping the header labels on top are removed and they disappear to the top of the scroll view (where they are in the storyboard).

Broken calendar view part 1 Column headers are not visible due to being scrolled offscreen

Broken calendar view part 2 Column headers are positioned at top of the scroll view (and the height is wrong).

Setup

A user can switch between dates ("Today" button at the top with left-right-arrows) and switch between the people displayed. (swipe/pan on view) Boh interactions are realized with UIPageViewControllers inside one another. The outer page view controller switches between dates, the inner one between the pages of columns (with people). The time view is contained in the outer page view controller but not the inner one.

So the hierarchy of views and controllers looks like this:

Calendar PageViewController (the one controlled via buttons in the navigation bar)
-- Scroll View of Page View Controller
---- Team View Controller (with view)
------ Header View reference (see screenshot 2)
------ Scroll View for vertical scrolling
-------- Time View (the one on the left)
-------- People PageViewController (the one controlled by swiping left/right)
---------- ScrollView of Page View Controller
------------ ViewController for a Single Page (with view)
--------------(1-n) Container View Controller (several of those, in the example 4 per page)
---------------- Column View Controller
------------------ Header View (A UIVisualEffectsView with a label)
------------------ calendar column view (the one doing the horizontal stripes)

Two pin the header views of the individual column view controllers to the top I use a reference view outside of the inner page view controller and outside of the vertical scroll view. I called it Header View reference in the overview above and you can see it quite nicely in the broken example:

Header View Reference

It's a simple UIVisualEffectsView that I constrained to be at the top left with a fixed height and same width as the time view. So this view has the correct position and height I want all my header views to have and I use that to constraint the individual column header views to it in code (all other constraints are set up in storyboards) in the updateViewConstraints method of each ColumViewController like so:

- (void)updateViewConstraints
{
    DDLogDebug(@"updating view constraints in colum view controller for %@", self.employee.displayName);
    UIView *baseView = self.headerViewReferenceContainer.viewForHeaderContainerViewConstraints;
    UIView *containerReference = self.headerViewReferenceContainer.headerContainerViewReference;
    NSParameterAssert([containerReference isDescendantOfView:baseView] && [self.headerContainerView isDescendantOfView:baseView]);
    [baseView addConstraint:[NSLayoutConstraint constraintWithItem:self.headerContainerView
                                                     attribute:NSLayoutAttributeTop
                                                     relatedBy:NSLayoutRelationEqual
                                                        toItem:containerReference
                                                     attribute:NSLayoutAttributeTop
                                                        multiplier:1.0
                                                          constant:0]];

    [baseView addConstraint:[NSLayoutConstraint constraintWithItem:self.headerContainerView
                                                         attribute:NSLayoutAttributeHeight
                                                     relatedBy:NSLayoutRelationEqual
                                                        toItem:containerReference
                                                     attribute:NSLayoutAttributeHeight
                                                        multiplier:1.0
                                                          constant:0]];

    [super updateViewConstraints];
}

baseView is the view to which the constraints should be added. It must be a superview of both the header reference view and the header view of the column view controller. At the moment this will be the view of the Team View Controller above (the one containing the vertical scroll view).

containerReference is the Header View Reference view as seen in the screenshot above.

So I constrain the column header views to have the same top position and same height as the reference view. (the width and x position depends on the column)

As you can see in the broken screenshots the Header View reference is always positioned correctly. Also, on loading the view controllers and there views, the constraints are setup correctly.

Then when starting a pan gesture which switches the page of the PeoplePageView Controller and loads the next ViewController for a Single Page the constraints somehow disappear and the header views move to the top of the scroll view due to no longer being pinned to the top.

The next View Controller for a Single Page has the constraints set up correctly, but only until it snaps into place.

While Panning

So while panning the constraints of the view controller to display are setup correctly. But as soon as it snaps into place (feels like it happens at -viewDidAppear) the constraints are removed as well.

I don't understand why and how the constraints are removed.

What is not wrong / what I tried

It could be due to one of the related views disappearing, but

  • the baseView doesn't change at all on swiping left right as it is a superview of the paging scroll view
  • the header reference view is not changed as it is not contained in the paging Page View Controller.
  • the header column view does not disappear until the view controller is fully offscreen, so the layout should not break during the paging

I used to have an issue with the layout only falling into place AFTER the paging finished. This was due to my constraints relating to the top layout constraints as described in iOS 8 UIPageViewController Applying Constraints After Transitions. As a result I use a fixed distance from the super view's top to pin my Header View Reference instead of pinning it to the top layout constraint.

So the bug of the UIPageViewController not having a correct top layout constraint on paging shouldn't be the issue either.

I do not add any views programatically. All my Storyboards use auto layout and view controller are added to each other via Container View Controllers in the Storyboard or implementing a UIPageViewControllerDatasource and instantiating the view controller from a xib. So there shouldn't be any issues related to translatesAutoResizingMasks. Also, it works on load, it only breaks on paging, this doesn't fit the usual translatesAutoResizingMasks = YES problem.

I also do not get any errors about conflicting constraints or the view hierarchy not being prepared for constraints, the working constraints are simply removed.

I due have one issue which I think is unrelated, but I'll list it here for completeness' sake: I get an error due to the layout constraints of the labels inside the column header views being added to early:

The view hierarchy is not prepared for the constraint: <NSLayoutConstraint:0x7af4df20 UILabel:0x7af4e0d0'Susanne'.width == UIVisualEffectView:0x7af4de70.width>
When added to a view, the constraint's items must be descendants of that view (or the view itself). This will crash if the constraint needs to be resolved before the view hierarchy is assembled. Break on -[UIView _viewHierarchyUnpreparedForConstraint:] to debug.

However, that message complains about the relation between the label and its containing view (the header view of the column), not about the "outer" constraints of the header view. Also, it warns that the app will crash if the constraints need to be resolved too early. The app does not crash, so the timing seems to be right. Also, the constraints the app complains about are setup in a storyboard, so I don't see how I could get the timing wrong.

tl;dr: Constraints that reach through several levels of the view hierarchy from inside a UIPageViewController to a view outside of it are removed (without warning, comment, reason or anything). I don't understand why and how to prevent that from happening or at what point to add them again.

UPDATE:

I added the following call to viewWillAppear::

[self.view setNeedsUpdateConstraints]

This has the following effect:

  • When dragging the page to the side a bit (not switching the paging) and releasing it again (so it snaps back) the broken constraints are repaired again (and the layout is fixed). This is due to the page view controller calling viewWillAppear: on the original view controller if it "snaps back" (to offset the previously issued viewWillDisappear call).
  • But during the scrolling the layout is still broken.
  • Also when swiping to a new page, the constraints of the new page are also broken.
  • But whenever viewWillAppear is called the constraints are fixed for a short time until they are removed again (usually when the scroll ends).

If I add the same call to viewDidAppear instead of viewWillAppear the following happens:

  • This leads to the layout being correct most of the time (they are fixed after scrolling ends).
  • But it is still broken during scrolling.

Now if I add [self.view setNeedsUpdateConstraints] to both methods the following happens:

  • The layout behaves correctly almost all of the time, no strange jumping around of views
  • Except for the first page change. During that one the constraints of the original page seem to be removed and the layout is broken in the way shown above. After that, the constraints seem to be fixed permanently and swiping back and forth works without issues.
  • This is even true for three pages (I also tried it with four) when paging to the last page (and trying to go beyond it) and back although I'm pretty sure the page view controller does not keep all three pages in memory.
Community
  • 1
  • 1
Joachim Kurz
  • 2,875
  • 6
  • 23
  • 43
  • I would suggest using UICollectionView for creating calendar. – Cy-4AH Feb 24 '15 at 14:49
  • Thanks for the suggestion. I thought about that but didn't want to role my own UICollectionViewLayout. In my hierarchy I can simply add an appointment to one of the column view controller and don't have to care about how the layout of others works. In the same way, inside the column view controller I only have to care about the vertical position and can add a vertical constraint once and be done with it. With a UICollectionViewLayout I would have to relayout the view on every bounds change and implement paging myself. – Joachim Kurz Feb 24 '15 at 14:54
  • I would never ignore errors/warnings, these are always will lead into some unexpected behavior, like disappearing or something, if it's not related to your current issue, it will be related to another issue later on, probably all are related... – oren Mar 01 '15 at 21:58
  • Right, but I'm trying to fix one issue after the other. ;-) and in that case, the only constraint it complains about is not related to my issue. – Joachim Kurz Mar 02 '15 at 09:56

4 Answers4

3

Is there a chance that updateViewConstraints is being called more than once causing multiple additions of the same constraints which is then causing an issue. I believe this can happen quite easily.

I notice in numerous discussions that many seem to recommend not doing the create and add in this function, (viewDidLoad seems preferred) and to only do the constraint value settings in this function to minimize the issue of multiple calls but to ensure the sizes are correct. Another option seems to be to put a Boolean guard for the controller around constraint addition to make sure it only occurs once in the lifetime of the controller.

Might not be your issue, but thought worth mentioning.

Rory McKinnel
  • 7,936
  • 2
  • 17
  • 28
  • It is perfectly possible that updateConstraints is called several times. However, the constraints added should be identical and having the same constraint twice should not be much of an issue. Also: I checked in the debugger and instead of seeing duplicate contstraints I see NO constraints, so it definitely feels like the constraints have been REMOVED not added twice. – Joachim Kurz Mar 10 '15 at 08:45
  • Seems odd they would be removed indeed. Any chance the baseView could have been replaced rather than the constraints having being removed? Worth checking the addresses of baseView: you've probably done that though. Also might be worth using the 3D view hierarchy debugger to see the view layers. Might show up what is happining depth wise in the views which look wrong. – Rory McKinnel Mar 10 '15 at 10:50
  • I used the 3D view hierarchy debugger to check whether the constraints are there or not and this is how I found out they disappeared. This also confirmed that all the views I expect are actually there. I will check the address of the `baseView` again, but it shouldn't be replaced, I think, because it is outside the content of the scroll view which scrolls. And only the scrolling seems to remove the constraints. – Joachim Kurz Mar 10 '15 at 14:36
  • Btw: Regarding the suggestion of putting the constraint construction in `viewDidLoad:`: I need all the parent view controllers to be setup up correctly as well so all the views I connect to via constraints are actually set up as well. However, I don't have close control over view `viewDidLoad` method of which child or contain view controller is called in what order, so I can't rely on that. So I need a method that is called *after* `viewDidLoad`. `viewWillAppear` comes to mind, but it is called too often (e.g. each time the user only scrolls in part of the view). Thus: `updateConstraints` – Joachim Kurz Mar 10 '15 at 14:39
  • 1
    In that case I think its worth adding a BOOL property you can use as a guard so you only add them once. Just for correctness and to rule that out as an issue. Also did you try calling `[super updateViewConstraints];` before your changes instead of after? – Rory McKinnel Mar 10 '15 at 14:49
  • Yes, if I call `[super updateViewConstraints]` before adding my constraints I get an exception: `*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'The layout constraints still need update after sending -updateViewConstraints to . PRCalendarColumnViewController or one of its superclasses may have overridden -updateViewConstraints without calling super or sending -updateConstraints to the view. Or, something may have dirtied layout constraints in the middle of updating them. Both are programming errors.'` – Joachim Kurz Mar 10 '15 at 15:57
  • Instead of the guard I did the following: I call `[self.view setNeedsUpdateConstraints]` in `viewWillAppear`. this ensure `updateViewConstraints` is called on every `viewWillAppear` call. This only solves the problem partially: When dragging the scroll view to the left and releasing again (not changing the page) the constraints are updated correctly again (because viewWillAppear is called again) but when changing the page, the constraints of the new page are removed anyway (since `viewWillAppear` is not called after the scroll finishes). – Joachim Kurz Mar 10 '15 at 16:04
  • Adding a `[self.view setNeedsUpdateConstraints]` in `viewDidAppear` as well also fixes the constraints when the scrolling finishes. But during the scrolling they are still broken, so adding calls here only fixes a few of the symptoms, not the root problem, I think. – Joachim Kurz Mar 10 '15 at 16:07
  • I added more details about how `viewWillAppear` and `viewDidAppear` do not solve the problem. – Joachim Kurz Mar 10 '15 at 16:26
  • Rather than calling `setNeedsUpdateConstraints` which is a one off, try overriding `needsUpdateConstraints` and always return TRUE. – Rory McKinnel Mar 10 '15 at 17:57
  • That sounds like a really bad idea, causing updateConstraints to be called almost all the time. Even if I guard against repeated calls, I can't ensure Apple does in the method's super implementation. – Joachim Kurz Mar 10 '15 at 17:59
  • Probably is a bad idea, but might be interesting to see what behavior you get given adding one `setNeedsUpdateConstraints` has changed the behavior. Not much else to try. – Rory McKinnel Mar 10 '15 at 18:20
0

Try setting your constraints in viewDidLayoutSubviews. I am not quite certain but I remember solving a constraint related issue this way and it seemed to work.

Sumeet
  • 1,055
  • 8
  • 18
  • If I remember correctly the system complained about the constraints being still dirty after viewDidLayoutSubviews was called when I tried that in that past. Would you recommend calling it before or after the call to `super viewDidLayoutSubviews`? – Joachim Kurz Mar 10 '15 at 15:55
  • I just tried it. The problem with `viewDidLayoutSubviews` is that it is called really often. Whenever the bounds change (e.g. whenever the view is scrolled vertically) it is called. But I do not need to update the constraints in that case, I only need to set it up once. Yes, I could use a guard and ensure I only do it once, but a method that is called on every bounds change does not seem to be the correct place for setting up constraints. – Joachim Kurz Mar 10 '15 at 16:00
  • Just using a guard to ensure it is called once didn't solve my problem. But I was inspired by your idea and came up with a working solution, see my answer. – Joachim Kurz Mar 24 '15 at 13:55
0

Why don't you set the top of the headerView to the top of the baseview. i.e. the view that contains the scrollview. This will fix your issue for sure.

Sumeet
  • 1,055
  • 8
  • 18
  • This is basically what I do. I also need a height, so I introduced the `Header View Reference`. The `Header View Reference` is not part of the scroll view but a sibling of it (see list in question) and its top is constrained with "Top Space to Superview = 64" (because attaching it to top layout guide does to work as explained in the linked question). I then set the tops of the `Header View`s to be the same as the top of the header view reference, which transitively constraints the to the scroll view's container view's top. __So this is actually exactly what I tried__. ;-) – Joachim Kurz Mar 11 '15 at 10:00
  • Yeah i hear you, but as you are attaching the top of the `header view` to the `header view reference`, the `header reference view` is not itself ready and is getting adjusted according to its constraints while you are scrolling between the page and sits fine eventually but the `header view` which depends on this reference view for its top has nothing to refer to since when it wants to adjust according to its sibling constraints the sibling himself is under the process of getting adjusted. I feel you just need to try setting the top to the `baseView`'s top. No harm in trying, now is there. – Sumeet Mar 11 '15 at 15:54
  • Well, all the constraints are resolved at the same time, so the order of adding them should not matter. I will try it, but I doubt it changes anything. – Joachim Kurz Mar 11 '15 at 16:01
0

Ok, I still think it is a bug, but I found a workaround:

I basically have two constraint I want to keep and which sometimes disappear. I create properties for these, but make them weak:

@property (weak) NSLayoutConstraint *headerViewHeightConstraint;
@property (weak) NSLayoutConstraint *headerViewTopConstraint;

I then assign the created constraints to these properties in updateViewConstraints like this:

NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:self.headerContainerView
                                                                 attribute:NSLayoutAttributeTop
                                                                 relatedBy:NSLayoutRelationEqual
                                                                    toItem:containerReference
                                                                 attribute:NSLayoutAttributeTop
                                                                multiplier:1.0
                                                                  constant:0];
[baseView addConstraint:topConstraint];
self.headerViewTopConstraint = topConstraint;

By using a weak reference I can check whether the constraint exists without keeping it around if nothing else references it. This way, when the constraint is removed from the view (for whatever reason) it will be deallocated an my property will be nil. I can use that to check whether I need to reestablish the constraint like this:

- (void)reestablishHeaderViewConstraintsIfNecessary
{
    if (!self.headerViewTopConstraint || !self.headerViewHeightConstraint) // if any of the constraints is missing
    {
        [self.view setNeedsUpdateConstraints];
    }
}

Now I just need a place to call that method from, to make sure it is called whenever the constraints might have been removed. As described in the question viewWillAppear and viewDidAppear are not good enough. Instead I use viewWillLayoutSubviews. This is called whenever the bounds change (e.g. many times during scrolling), which is quite often, but because I wrap the actual constraint invalidation this should be cheap enough if nothing needs to be done:

- (void)viewWillLayoutSubviews
{
    [self reestablishHeaderViewConstraintsIfNecessary];
    [super viewWillLayoutSubviews];
}

I think it is important to use viewWillLayoutSubviews, not viewDidLayoutSubviews ad invalidating the layout in viewDidLayoutSubviews leads to an exception but I'm not sure. This is also the reason why I put the call in front of the call to [super viewWillLayoutSubviews], but I didn't try whether it would work with the call appearing after it.

Joachim Kurz
  • 2,875
  • 6
  • 23
  • 43