使用自动布局在 UIScrollView 中使用浮动视图滚动犹豫

Posted

技术标签:

【中文标题】使用自动布局在 UIScrollView 中使用浮动视图滚动犹豫【英文标题】:Scroll hesitation with floating view in UIScrollView using auto layout 【发布时间】:2015-04-08 21:29:47 【问题描述】:

问题: 我将简化问题,但保留我的原件以供参考...

我正在修改现有约束的优先级,但结果仅更改了 UIScrollView 子视图之一的位置。所有剩余的子视图都保持其原始大小和位置,但看起来我正在对我正在修改约束的子视图下的所有子视图进行布局传递。那么,为什么 ViewWillLayoutSubviews 和 UpdateViewConstraints 会在没有改变的事情上被调用呢?

[原问题] 请参阅下面的详细信息。在包含的截屏视频中看到滚动犹豫的原因是什么?我该如何解决?

背景: 我已经构建了一个手风琴样式列表控件,它承载了几个子 UIViewController 的视图,每个都与一个标题视图配对,以使用户能够切换其内容视图的可见性。我使用带有自动布局的 UIScrollView 创建了这个列表控件。我已经非常熟悉使用 UIScrollView 进行自动布局的复杂性,但承认我对自动布局总体上还是很陌生。我非常依赖 Apple 的文档和社区中的相关博客文章:

Apple's Documentation Relevant *** questions 还有很多很多其他的。

我已经实现了这个控件,以便标题视图可以浮动在其他 UIScrollView 内容之上。非常类似于分组 UITableView 的截面视图,当用户向下滚动以查看更多内容时,它们将粘在 UIScrollView 的顶部。顺便说一句,我最初是使用 UITableView 构建的,但是它管理可见单元格的方式导致了它自己的滚动性能问题。

问题: 我在滚动内容时遇到了一些性能问题。我做了一些故障排除,我发现当“浮动标题”功能被禁用时,滚动性能非常好(尽管在展开/折叠可能与我的滚动原因相同的部分时仍有一些犹豫性能问题)。但是启用此功能后,每个标题视图都浮动时滚动会犹豫。我已经包含了我在 iPod Touch 5 上运行的原型的截屏视频。

Screencast of prototype running on iPod Touch 5

这是一个非常小的犹豫,但这个原型的内容视图明显不那么复杂。最终项目表现出长达约一秒钟的犹豫。

详情: 原型是使用 Xamarin 构建的,但如果您想回答的话,我精通 Objective-C。以下是我设置约束以支持此功能的方式。我在修改 UIScrollView 子视图的 Reload() 方法中完成了这项工作。


UIView previousContent = null;

for (var sectionIdx = 0; sectionIdx < this.Source.NumberOfSections (this); sectionIdx++) 
    var vwHeader = this.Source.GetViewForHeader (this, sectionIdx);
    var vwContent = this.Source.GetViewForSection (this, sectionIdx);
    this.scrollView.AddSubview (vwHeader);

    this.scrollView.AddSubview (vwContent);

    this.scrollView.BringSubviewToFront (vwHeader);

    var headerHeight = this.Source.GetHeightForHeader (this, sectionIdx);
    var isSectionCollapsed = this.Source.GetIsSectionCollapsed (this, sectionIdx);

    // This will never change, so set constraint priority to Required (1000)
    var headerHeightConstraint = NSLayoutConstraint.Create (vwHeader, NSLayoutAttribute.Height, NSLayoutRelation.Equal, null, NSLayoutAttribute.Height, 1.0f, headerHeight);
    headerHeightConstraint.Priority = (float)UILayoutPriority.Required;

    this.AddConstraint (headerHeightConstraint);

    // This constraint is used to handle visibility of a section.

    // This is updated in UpdateConstraints.

    var contentZeroHeightConstraint = NSLayoutConstraint.Create (vwContent, NSLayoutAttribute.Height, NSLayoutRelation.Equal, null, NSLayoutAttribute.Height, 1.0f, 0.0f);

    if (isSectionCollapsed)
        contentZeroHeightConstraint.Priority = (float)UILayoutPriority.Required - 1.0f;
    else
        contentZeroHeightConstraint.Priority = (float)UILayoutPriority.DefaultLow;

    this.AddConstraint (contentZeroHeightConstraint);

    
    // Set initial state of dictionary that keeps track of all inline and floating header constraints
    if (!this.inlineConstraints.ContainsKey (sectionIdx))
        this.inlineConstraints.Add (sectionIdx, new List<NSLayoutConstraint> ());

    this.inlineConstraints [sectionIdx].Clear ();
    if (!this.floatConstraints.ContainsKey (sectionIdx))

        this.floatConstraints.Add (sectionIdx, new List<NSLayoutConstraint> ());
    this.floatConstraints [sectionIdx].Clear ();

    
    // If this is the first section, pin top edges to the scrollview, not the previous sibling.

    if (previousContent == null) 

        // Pin the top edge of the header view to the top edge of the scrollview.
        var headerTopToScrollViewTopConstraint = NSLayoutConstraint.Create (vwHeader, NSLayoutAttribute.Top, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Top, 1.0f, 0.0f);
        headerTopToScrollViewTopConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
        // Add this constraint to the dictionary that tracks inline constraints, because we will need to change it when this header view needs to float.

        this.inlineConstraints [sectionIdx].Add (headerTopToScrollViewTopConstraint);
        this.AddConstraint (headerTopToScrollViewTopConstraint);

        // Also pin the top edge of the content view to the top edge of the scrollview, with a padding of header height.
        // This is done to minimize constraints that need to be modified when a header is floated.
 
        // May be safely changed to pin to the bottom edge of the header view.
        var contentTopToScrollViewTopConstraint = NSLayoutConstraint.Create (vwContent, NSLayoutAttribute.Top, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Top, 1.0f, headerHeight);
        contentTopToScrollViewTopConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
        this.AddConstraint (contentTopToScrollViewTopConstraint);
     else 
        // Pin the top edge of the header view to the bottom edge of the previous content view.
        var previousContentBottomToHeaderTopConstraint = NSLayoutConstraint.Create (previousContent, NSLayoutAttribute.Bottom, NSLayoutRelation.Equal, vwHeader, NSLayoutAttribute.Top, 1.0f, 0.0f);
        previousContentBottomToHeaderTopConstraint.Priority = (float)UILayoutPriority.DefaultHigh;

        // Add this constraint to the dictionary that tracks inline constraints, because we will need to change it when this header view needs to float.

        this.inlineConstraints [sectionIdx].Add (previousContentBottomToHeaderTopConstraint);

        this.AddConstraint (previousContentBottomToHeaderTopConstraint);

        // Also pin the top edge of the content view to the bottom edge of the previous content view.
        // This is done to minimize constraints that need to be modified when a header is floated.
        // May be safely changed to pin to the bottom edge of the header view.

        var previousContentBottomToContentTopConstraint = NSLayoutConstraint.Create (previousContent, NSLayoutAttribute.Bottom, NSLayoutRelation.Equal, vwContent, NSLayoutAttribute.Top, 1.0f, -headerHeight);

        previousContentBottomToContentTopConstraint.Priority = (float)UILayoutPriority.DefaultHigh;

        this.AddConstraint (previousContentBottomToContentTopConstraint);
    

    // If this is the last section, pin the bottom edge of the content view to the bottom edge of the scrollview.
    if (sectionIdx == this.Source.NumberOfSections (this) - 1) 
        var contentBottomToScrollViewBottomConstraint = NSLayoutConstraint.Create (vwContent, NSLayoutAttribute.Bottom, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Bottom, 1.0f, 0.0f);
        contentBottomToScrollViewBottomConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
        this.AddConstraint (contentBottomToScrollViewBottomConstraint);
    

    // Pin the leading edge of the header view to the leading edge of the scrollview.
    var headerLeadingToScrollViewLeadingConstraint = NSLayoutConstraint.Create (vwHeader, NSLayoutAttribute.Leading, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Leading, 1.0f, 0.0f);
    headerLeadingToScrollViewLeadingConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
    
    // Add this constraint to the dictionary that tracks inline constraints, because we will need to change it when this header view needs to float.
    this.inlineConstraints [sectionIdx].Add (headerLeadingToScrollViewLeadingConstraint);
    this.AddConstraint (headerLeadingToScrollViewLeadingConstraint);

    // Pin the leading edge of the content view to the leading edge of the scrollview.
    var contentLeadingToScrollViewLeadingConstraint = NSLayoutConstraint.Create (vwContent, NSLayoutAttribute.Leading, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Leading, 1.0f, 0.0f);
    contentLeadingToScrollViewLeadingConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
    this.AddConstraint (contentLeadingToScrollViewLeadingConstraint);

    // Pin the trailing edge of the header view to the trailing edge of the scrollview.
    var headerTrailingToScrollViewTrailingConstraint = NSLayoutConstraint.Create (vwHeader, NSLayoutAttribute.Trailing, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Trailing, 1.0f, 0.0f);
    headerTrailingToScrollViewTrailingConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
    // Add this constraint to the dictionary that tracks inline constraints, because we will need to change it when this header view needs to float.
    this.inlineConstraints [sectionIdx].Add (headerTrailingToScrollViewTrailingConstraint);
    this.AddConstraint (headerTrailingToScrollViewTrailingConstraint);

    // Pin the trailing edge of the content view to the trailing edge of the scrollview.
    var contentTrailingToScrollViewTrailingConstraint = NSLayoutConstraint.Create (vwContent, NSLayoutAttribute.Trailing, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Trailing, 1.0f, 0.0f);
    contentTrailingToScrollViewTrailingConstraint.Priority = (float)UILayoutPriority.DefaultHigh;
    this.AddConstraint (contentTrailingToScrollViewTrailingConstraint);

    // Add a width constraint to set header width to scrollview width.
    var headerWidthConstraint = NSLayoutConstraint.Create (vwHeader, NSLayoutAttribute.Width, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Width, 1.0f, 0.0f);
    headerWidthConstraint.Priority = (float)UILayoutPriority.Required;
    this.AddConstraint (headerWidthConstraint);

    // Add a width constraint to set content width to scrollview width.
    var contentWidthConstraint = NSLayoutConstraint.Create (vwContent, NSLayoutAttribute.Width, NSLayoutRelation.Equal, this.scrollView, NSLayoutAttribute.Width, 1.0f, 0.0f);
    contentWidthConstraint.Priority = (float)UILayoutPriority.Required;
    this.AddConstraint (contentWidthConstraint);

    // Add a lower priority constraint to pin the leading edge of the header view to the leading edge of the parent of the scrollview.
    var floatHeaderLeadingEdgeConstraint = NSLayoutConstraint.Create (vwHeader, NSLayoutAttribute.Leading, NSLayoutRelation.Equal, this, NSLayoutAttribute.Leading, 1.0f, 0.0f);
    floatHeaderLeadingEdgeConstraint.Priority = (float)UILayoutPriority.DefaultLow;
    // Add this constraint to the dictionary that tracks floating constraints, because we will need to change it when this header view needs to be inline.
    this.floatConstraints [sectionIdx].Add (floatHeaderLeadingEdgeConstraint);
    this.AddConstraint (floatHeaderLeadingEdgeConstraint);

    // Add a lower priority constraint to pin the top edge of the header view to the top edge of the parent of the scrollview.
    var floatHeaderTopEdgeConstraint = NSLayoutConstraint.Create (vwHeader,  NSLayoutAttribute.Top, NSLayoutRelation.Equal, this, NSLayoutAttribute.Top, 1.0f, 0.0f);
    floatHeaderTopEdgeConstraint.Priority = (float)UILayoutPriority.DefaultLow;
    // Add this constraint to the dictionary that tracks floating constraints, because we will need to change it when this header view needs to be inline.
    this.floatConstraints [sectionIdx].Add (floatHeaderTopEdgeConstraint);
    this.AddConstraint (floatHeaderTopEdgeConstraint);

    // Add a lower priority constraint to pin the trailing edge of the header view to the trailing edge of the parent of the scrollview.
    var floatHeaderTrailingEdgeConstraint = NSLayoutConstraint.Create (vwHeader,  NSLayoutAttribute.Trailing, NSLayoutRelation.Equal, this, NSLayoutAttribute.Trailing, 1.0f, 0.0f);
    floatHeaderTrailingEdgeConstraint.Priority = (float)UILayoutPriority.DefaultLow;
    // Add this constraint to the dictionary that tracks floating constraints, because we will need to change it when this header view needs to be inline.
    this.floatConstraints [sectionIdx].Add (floatHeaderTrailingEdgeConstraint);
    this.AddConstraint (floatHeaderTrailingEdgeConstraint);

    previousContent = vwContent;

UIScrollView 中的所有内容都需要前导、顶部、尾部和底部边缘约束,以便 UIScrollView 可以确定其 ContentSize,所以我已经这样做了。如您所见,我添加了浮动标题约束,即使在执行时没有标题应该浮动。我给了它们较低的优先级,因此默认情况下不会应用它们。我对折叠部分的内容高度约束做了同样的事情。我这样做是为了不必添加/删除约束来浮动标题或折叠部分,我只需要修改约束优先级。我不知道这是否是一个好习惯,但我认为这可能有助于避免不必要的布局传递。

我正在跟踪适用于内联和浮动标题的约束。当确定应该浮动标题时,我将相关内联标题约束的优先级降低到 DefaultLow,并将相关浮动标题约束的优先级提高到 DefaultHigh。我在 UIScrollView 的 Scrolled 事件的事件处理程序中执行此操作。我确定哪个部分占用了 ContentOffset 的空间并浮动其标题。我正在跟踪浮动的标题的最后一个索引,只是为了避免内联不需要内联的内容。



    private int lastFloatHeaderIdx = -1;
    private void scrolled (object sender, EventArgs e) 
        // Restore the code below to see the scroll hesitation from what I think are unnecessary calls to ViewWillLayoutSubviews and UpdateViewConstraints
        // How can I achieve this behavior without incurring the unnecessary expense?
        if (this.Source != null) 
            for (var idx = 0; idx < this.Source.NumberOfSections (this); idx++) 
                var headerHeight = this.Source.GetHeightForHeader (this, idx);
                var vwContent = this.Source.GetViewForSection (this, idx);
                var sectionFrame = new CGRect (new CGPoint(vwContent.Frame.X, vwContent.Frame.Y - headerHeight), new CGSize(vwContent.Frame.Width, headerHeight + vwContent.Frame.Height));
                var scrollContent = new CGRect (this.scrollView.ContentOffset.X, this.scrollView.ContentOffset.Y, this.scrollView.Frame.Width, 1.0f);
                if (sectionFrame.IntersectsWith (scrollContent)) 
                    this.floatHeader (idx);
                 else if (idx > this.lastFloatHeaderIdx)  // This is an unnecessary optimization. Appears to have no effect.
                    var inlines = this.inlineConstraints [idx];
                    if (inlines.Count > 0 && inlines [0].Priority < (float)UILayoutPriority.DefaultHigh)  // This is also an unnecessary optimization. Appears to have no effect.
                        this.inlineHeader (idx);
                    
                
            
        
    

我通过将日志记录添加到子 UIViewController 的 ViewWillLayoutSubviews 和 UpdateViewConstraints 完成了一些额外的故障排除,我可以看到,当标题浮动时,布局传递会在前一个内容视图和它下面的所有视图上完成。我相信这是犹豫的原因。我不认为布局传递包含以前的内容是巧合。要浮动标题,我必须取消将其顶部边缘固定到前一个内容视图底部的约束的优先级,并提高将其顶部边缘固定到 UIScrollView 顶部边缘的约束的优先级。

但是由于 UIScrollView 中内容视图的大小和位置没有改变,我认为我不应该对任何东西进行布局传递。而且,我发现有时我没有。例如,如果我轻弹以快速滚动到底部,标题会像预期的那样一个接一个地浮动,但不会发生布局传递——至少在滚动速度变慢之前不会发生。我已经包含了我在模拟器中运行的原型的截屏视频,并带有控制台输出。

Screencast of prototype running in the simulator with console output

我还提供了源链接。

Archive of source

【问题讨论】:

我真的很希望能够将截屏视频包含在这个问题中。另外,我想让包含的超链接打开一个新窗口,而不是离开我的问题。很好奇如何做到这一点。 几件事。您似乎已经重新发明了 UITableView ,其中包括您的浮动标题,这是一项了不起的壮举。您是在回收标题视图还是每次都重新实例化? 我正在回收它们。当我使用 UITableView 执行此操作时,我之前曾问过一个自动布局问题:***.com/questions/26435778/…。我认为这是一个 IntrinsicContentSize 问题。 【参考方案1】:

虽然我认为通过UITableView 而不是重新发明UITableView 来解决您提到的性能问题可能会更好,但这里肯定有一些地方看起来很可疑。你应该首先通过 Instruments 运行你的代码,看看真正的问题在哪里。在不花时间分析的情况下尝试优化通常是徒劳的。

但是,让我们看一下循环的某些部分。循环往往是问题所在。

        for (var idx = 0; idx < this.Source.NumberOfSections (this); idx++) 
            var headerHeight = this.Source.GetHeightForHeader (this, idx);
            var vwContent = this.Source.GetViewForSection (this, idx);
            var sectionFrame = new CGRect (new CGPoint(vwContent.Frame.X, vwContent.Frame.Y - headerHeight), new CGSize(vwContent.Frame.Width, headerHeight + vwContent.Frame.Height));
            var scrollContent = new CGRect (this.scrollView.ContentOffset.X, this.scrollView.ContentOffset.Y, this.scrollView.Frame.Width, 1.0f);

这会重复调用很多您不需要的函数。 NumberOfSections 只能调用一次。 GetHeightForHeader 最好非常便宜,否则您应该将其结果缓存在一个数组中。同样GetViewForSection。如果这不是一个简单的数组查找,你应该把它变成一个。您还为每个部分生成scrollContent,但始终相同。

最后,我将重点介绍floatHeaderinlineHeader。确保这些已经知道它们的确切值并且不必计算很多东西。您的循环应该什么都不做,只需要找到与当前 Y 坐标重叠的 Y 坐标范围的视图(您不需要完整的 IntersectsWith,只需 Y 坐标),然后调整 1 或 2 视图的 Y 坐标(当前浮动视图,或前一个浮动视图和新的浮动视图)。您应该不需要在这里进行任何其他操作。

但第一步是通过 Instruments 运行它,看看会跳出什么。

【讨论】:

感谢您的建议。我认为任何遇到或试图回答这个问题的人都会提出同样的建议来使用 UITableView。我是 2 对 2 的。 ;-) 是的,GetHeightForHeader 和 GetViewForSection 是简单的查找。 floatHeader(idx) 和 inlineHeader(idx) 只需遍历一个包含 3 个约束的数组,每个约束来更改它们的优先级。我可能需要按照您的建议将其转换为原生项目以通过 Instruments 运行。这是我使用 Xamarin 的主要问题,很难做到。 我对此最接近的解释是,我的一个子视图控制器有一些代码正在更新 viewWillLayoutSubviews 中的约束。首先,我没有为要更新的布局做任何事情,所以我仍然不确定为什么要执行此方法,但是由于我正在修改约束,即使它们的值最终相同,它也解释了为什么 updateViewConstraints 正在叫。我通过添加我自己的标志来解决这个问题,以便何时布局视图和更新约束并绕过这些对超级的调用。

以上是关于使用自动布局在 UIScrollView 中使用浮动视图滚动犹豫的主要内容,如果未能解决你的问题,请参考以下文章

在 UIScrollView 子视图中使用自动布局(以编程方式)

使用自动布局在 UIScrollView 中使用浮动视图滚动犹豫

在 iOS 中使用自动布局填充 UIScrollView 的内容

UIScrollView setContentSize 使用自动布局打破视图

使用自动布局在 UIScrollView 中将可变宽度的文本列居中

UIScrollView 不能使用情节提要垂直滚动(使用自动布局)?