5
votes

I'm using iOS 6, a paging UIScrollView, and pure auto layout.

Summary: I created a view controller that scrolls pages of content. Some of the views are created and configured in storyboard, others programmatically. Here's the view hierarchy:

- Main view (storyboard) 
  - UIScrollView (storyboard)
    - content view (programmatically)
      - subviews representing pages of content (programmatically)

The constraints for the scroll view are configured in IB. Here's how I configured the constraints for the content view in code:

- (void)viewDidLoad
{
   // ABPageScrollerContentView is a subclass of UIView; it overrides intrinsicContentSize; the size is calculated without referencing the scroll view's dimensions
   self.contentView = [[ABPageScrollerContentView alloc] init];
   self.contentView.translatesAutoresizingMaskIntoConstraints = NO;
   [self.pageScrollerView addSubview:self.contentView];

   // configure constraints between scroll view and content view...
   UIView *contentView = self.contentView;
   NSDictionary *viewsDictionary = NSDictionaryOfVariableBindings(contentView);

   [self.pageScrollerView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[contentView]|" options:0 metrics:0 views:viewsDictionary]];
   [self.pageScrollerView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[contentView]|" options:0 metrics:0 views:viewsDictionary]];

   // the content view's subviews are added/removed in the tilePages method (not shown); tilePages is called later in the view controller lifecycle...
}

If the user taps an edit button, another view controller is presented modally using a segue in the storyboard. After the view controller is dismissed, the system appears to inexplicably modify the frame of the content view even though the constraints are unchanged.

I dismiss the presented view controller in the following delegate method:

- (void)didExitEditPageViewVC:(id)controller
{
   // update currently displayed page view from data model...

   // logged content view frame = (0, 0; 1020, 460)

   [self dismissViewControllerAnimated:YES completion:^{

      // logged content view frame = (-170, 0; 1020, 460)
   }];
}

I don't understand how the x component of the frame's origin changed from 0 to -170. The constraints are identical before and after dismissing the view controller.

Here is the frame and the constraints right before calling the dismissViewControllerAnimated:completion: method:

(lldb) po self.contentView
$0 = 0x1ede2b40 <AEBPageScrollerContentView: 0x1ede2b40; frame = (0 0; 1020 460); layer = <CALayer: 0x1edd6f00>>

(lldb) po self.pageScrollerView.constraints
$1 = 0x1ed076c0 <__NSArrayM 0x1ed076c0>(
<NSLayoutConstraint:0x1ede2980 H:|-(0)-[AEBPageScrollerContentView:0x1ede2b40]   (Names: '|':UIScrollView:0x1edd3410 )>,
<NSLayoutConstraint:0x1eded480 H:[AEBPageScrollerContentView:0x1ede2b40]-(0)-|   (Names: '|':UIScrollView:0x1edd3410 )>,
<NSLayoutConstraint:0x1edecbc0 V:|-(0)-[AEBPageScrollerContentView:0x1ede2b40]   (Names: '|':UIScrollView:0x1edd3410 )>,
<NSLayoutConstraint:0x1ede1040 V:[AEBPageScrollerContentView:0x1ede2b40]-(0)-|   (Names: '|':UIScrollView:0x1edd3410 )>
)

Here is the frame and the constraints after the presenting view controller re-appears:

contentView = <AEBPageScrollerContentView: 0x1ede2b40; frame = (-170 0; 1020 460); layer = <CALayer: 0x1edd6f00>>

self.pageScrollerView.constraints =
(
    "<NSLayoutConstraint:0x1ede2980 H:|-(0)-[AEBPageScrollerContentView:0x1ede2b40]   (Names: '|':UIScrollView:0x1edd3410 )>",
    "<NSLayoutConstraint:0x1eded480 H:[AEBPageScrollerContentView:0x1ede2b40]-(0)-|   (Names: '|':UIScrollView:0x1edd3410 )>",
    "<NSLayoutConstraint:0x1edecbc0 V:|-(0)-[AEBPageScrollerContentView:0x1ede2b40]   (Names: '|':UIScrollView:0x1edd3410 )>",
    "<NSLayoutConstraint:0x1ede1040 V:[AEBPageScrollerContentView:0x1ede2b40]-(0)-|   (Names: '|':UIScrollView:0x1edd3410 )>"
)

Why did the content view's frame change unexpectedly? And why doesn't it match what is dictated by the constraints?

A delayed call to hasAmbiguousLayout returns false surprisingly. No exceptions are thrown. The scroll view even scrolls, albeit the content view is partly off-screen.

No where do I explicitly set the scroll view's content size; I leave that to the system. The content view has an intrinsic size (the content view's size appears to be fine; it's the content view's origin that is the problem).

The scroll view's content offset is the same before and after dismissing the view controller. However, the displacement of the x component of the content view's origin is proportional to the content offset. The greater the content offset, the more negative the x component of the content view's origin is after the modal view controller is dismissed. And, at a content offset of "zero", the x component is zero. So if the modal view controller is presented while viewing the first page of content (when the content offset is "zero"), the content view's frame is correct upon dismissal of the view controller. The content-offset-of-zero case is the only circumstance in which the content view's frame correctly reflects its constraints.

I have tried inserting calls to layoutIfNeeded in various places with no results.

Any suggestions?

2

2 Answers

7
votes

I created a UIScrollView subclass that works around this issue (which BTW is fixed in iOS7):

@interface ConstraintsSafeScrollView : UIScrollView
@end

@implementation ConstraintsSafeScrollView {
  CGPoint _savedContentOffset;
  UIEdgeInsets _savedContentInset;
}

- (void)willMoveToWindow:(UIWindow *)newWindow {
  if (newWindow) {
    // Reset the scrollview to the top.
    [super setContentOffset:CGPointMake(-_savedContentInset.left, -_savedContentInset.top)];
  }
  [super willMoveToWindow:newWindow];
}

// Overridden to store the latest value.
- (void)setContentOffset:(CGPoint)contentOffset {
  _savedContentOffset = contentOffset;
  [super setContentOffset:contentOffset];
}

// Overridden to store the latest value.
- (void)setContentInset:(UIEdgeInsets)contentInset {
  _savedContentInset = contentInset;
  [super setContentInset:contentInset];
}

- (void)didMoveToWindow {
  if (self.window) {
    // Restore offset and insets to their previous values.
    self.contentOffset = _savedContentOffset;
    self.contentInset = _savedContentInset;

  }
  [super didMoveToWindow];
}

@end
0
votes

Since the only case in which the content view's frame was correct after dismissal of the modal view controller was when the scroll view's content offset was "zero", I resolved the issue as follows:

// presenting view controller's implementation
self.contentOffsetBeforeModalViewControllerDismissal = self.pageScrollerView.contentOffset;

self.pageScrollerView.contentOffset = CGPointMake(0, 0); 

[self dismissViewControllerAnimated:YES completion:nil];

Then, I restored the actual content offset in the presenting view controller's viewDidLayoutSubviews method:

- (void)viewDidLayoutSubviews
{
    if (!CGPointEqualToPoint(self.contentOffsetBeforeModalViewControllerDismissal, CGPointZero)) {

       self.pageScrollerView.contentOffset = self.contentOffsetBeforeModalViewControllerDismissal;

       self.contentOffsetBeforeModalViewControllerDismissal = CGPointZero;
    }
}

As it turns out, I could not restore the content offset in the presenting view controller's viewWillAppear: method because it's too early. Restoring the content offset in viewDidAppear: was too late because it produced a jarring UI update. When working with auto layout, I have found that more often than not that viewDidLayoutSubviews is just right in terms of timing.

This feels like a bit of a hack; but it's a familiar hack: save a bit of state, let the system do its thing, restore that bit of state.

Unfortunately, another bug quickly surfaced. If I presented and dismissed the modal view controller, scrolled to another page, then presented the modal view controller again, the app would crash when the modal view controller was dismissed. No information was given in the console about why the app might have crashed. The exception was thrown at the call to dismissViewControllerAnimated:completion:.

This latter problem was resolved by turning off my custom page tiling method (which is triggered anytime the scroll view's content offset changes). The page tiling method makes sure that the correct pages of content are displayed and recycles subviews. I don't need page tiling to occur during my content-offset-dance, because the correct subviews are already loaded. So here's the final fix for dismissing the modal view controller without displacing the content view's frame or crashing the app:

// presenting view controller's implementation
self.disablePageTiling = YES // flag causes tilePages to do nothing

self.contentOffsetBeforeModalViewControllerDismissal = self.pageScrollerView.contentOffset;

self.pageScrollerView.contentOffset = CGPointMake(0, 0); // triggers tilePages, but nothing will happen because of the flag 

[self dismissViewControllerAnimated:YES completion:^{
    self.disablePageTiling = NO;
}]; 

The implementation of viewDidLayoutSubviews remains the same as above.

I don't consider these issues to be completely resolved by any means. When using auto layout with a scroll view, I'm consistently left with this nagging feeling that I'm writing bad code. So I welcome further insight. I'm also curious about whether or not iOS 7 will effect these issues.