Fork me on GitHub
代码改变世界

iOS开发tips-UIScrollView的Autlayout布局

2017-02-25 10:50 by KenshinCui, ... 阅读, ... 评论, 收藏, 编辑

UIScrollViewj尽管继承于UIView,但它是一个相对比较特殊的视图,特别是当它遇到了AutoLayout之后。在UIScrollView中使用AutoLayout的目的除了使用相对约束确定子控件的位置和大小外,更重要的是如何自动计算出UIScrollView的contentSize(关于使用UIScrollView并且最终手动指定contentSize的AutoLayout用法不再今天讨论之列,严格意义上来说这也不是一种真正的UIScrollView的AutoLayout应用)。

UIScrollView的特殊之处

所谓UIScrollView的特殊之处就在于当它遇到了AutoLayout之后其contentSize的计算规则有些特殊。首先contentSize是根据子视图的leading/trailing/top/bottom进行确定的,而子视图的位置约束又必须依赖于UIScrollView来确定。这就有点类似于前面UICollectionView自适应高度文章中提到的:UICollectionViewCell的大小计算就是计算contentView的大小,而contentView的大小计算依赖于子视图的leading/trailing/top/bottom,子视图的位置约束又依赖于contentView,此时只要子视图存在固有尺寸(intrinsicContentSize)或者指定了尺寸又设置了leading/trailing/top/bottom,AutoLayout布局引擎即可计算出contentView的大小。
再回到AutoLayout,其实它的contentSize计算原理和UICollectionViewCell自适应很是类似,只是UIScrollView内部并没有一个contentView的东西(但是可以想象其存在,方便后面的理解,不过要清楚UIScrollView滚动的本质并非包含一个contentView而是通过bounds和frame坐标体系转换来实现的),只要设置子视图的leading/trailing/top/bottom(通常是通过edges=0让子视图上下左右间距都为0保证整个视图都在UIScrollView可视范围之内),然后通过设置size(width/height)约束确定子视图大小进而由AutoLayout反向计算出UIScrollView的contentSize。
假设A是UIScrollView(蓝色)、B是子视图1(绿色)、C是子视图2(绿色)、D是contentSize的计算区域(灰色,事实上它不存在),要想让cotentSize可以自动计算只需要确定B、C上下左右布局间距,然后再指定B、C间距和尺寸之后AutLayout既可以自动推断出contentSize的大小,原理如下图(下图布局类似于下面Demo3):

demo

demo1 单个子视图布局

对于单个子视图布局比较简单,只要设置leading/trailing/top/bottom,再设置子视图的size(width/height)即可,当然如果子视图存在固有尺寸并且想要使用固有尺寸的话,则这一步也可以省略。例如下面demo中演示了一个UIScrollView包含一个UIImageView子视图的图片查看界面。在下面的布局中仅仅设置了UIImageView上下左右边距,而UIImageView存在固有尺寸,因此整个布局就相当简单了(AutoLayout布局使用SnapKit库)。

    class ImageViewController: UIViewController {   
        override func viewDidLoad() {
            super.viewDidLoad()
            
            self.view.addSubview(self.scrollView)
            self.scrollView.addSubview(self.imageView)
            self.scrollView.snp.makeConstraints { (make) in
                make.edges.equalTo(0.0)
            }
            
            self.imageView.snp.makeConstraints { (make) in
                // 下面的约束用于确定contentSize的边距约束(leading/trailing/top/bottom)
                // 而由于UIImageView和UILabel、UIButton一样存在固有尺寸(intrinsicContentSize),因此不需要其他size约束就可以计算出contentSize大小
                make.edges.equalTo(0.0)
            }
            
        }
        
        // MARK: - 私有属性
        private lazy var scrollView:UIScrollView = {
            let temp = UIScrollView()
            return temp
        }()
        
        private lazy var imageView:UIImageView = {
            let image = UIImage(named: "img")
            let temp = UIImageView(image:image)
            return temp
        }()
    
    }

demo2 多个子视图布局使用containerView

很多UIScrollView的AutoLayout的布局文章中都会提到使用一个容器视图包含多个子视图,然后分别完成子视图布局和容器视图在UIScrollView中的布局,以此来简化布局过程。下面的Demo中演示了一个图片分页查看的布局情况,containerView作为容器布局时设置上下左右间距,然后设置其高度等于UIScrollView高度(因为要实现左右滚动),而此时并不需要设置宽度,因为宽度的计算依赖于子视图。在containerView的子视图中只要设置子视图与containerView的边距及各自间距和宽度,之后AutoLayout就可以计算出containerView的宽度。如此一来containerView已经设置完了四周间距和尺寸就可以计算出contentSize。

    class SlideViewController: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            self.automaticallyAdjustsScrollViewInsets = false
            
            self.view.addSubview(self.scrollView)
            self.scrollView.addSubview(self.containerView)
            self.containerView.addSubview(self.firstImageView)
            self.containerView.addSubview(self.secondImageView)
            self.containerView.addSubview(self.thirthImageView)
            
            self.scrollView.snp.makeConstraints { (make) in
                make.top.equalTo(self.topLayoutGuide.snp.bottom)
                make.left.bottom.right.equalTo(0.0)
            }
            
            // 下面的约束确定了containerView的高度,相当于contentSize.height已经确定,width通过cotnentView的子视图确定即可
            self.containerView.snp.makeConstraints { (make) in
                make.edges.equalTo(0.0)
                make.height.equalTo(self.scrollView.snp.height)
            }
            
            self.firstImageView.snp.makeConstraints { (make) in
                make.top.left.bottom.equalTo(0.0)
                make.width.equalTo(self.scrollView.snp.width)
            }
            
            self.secondImageView.snp.makeConstraints { (make) in
                make.top.bottom.equalTo(0.0)
                make.left.equalTo(self.firstImageView.snp.right)
                make.width.equalTo(self.scrollView.snp.width)
            }
            
            self.thirthImageView.snp.makeConstraints { (make) in
                make.top.bottom.equalTo(0.0)
                make.left.equalTo(self.secondImageView.snp.right)
                make.width.equalTo(self.scrollView.snp.width)
                make.right.equalTo(0.0) // 确定右边距
            }
            
        }
        
        // MARK: - 私有属性
        private lazy var scrollView:UIScrollView = {
            let temp = UIScrollView()
            temp.isPagingEnabled = true
            return temp
        }()
        
        private lazy var containerView:UIView = {
            let temp = UIView()
            return temp
        }()
        
        private lazy var firstImageView:UIImageView = {
            let image = UIImage(named: "1")
            let temp = UIImageView(image:image)
            temp.contentMode = .scaleAspectFill
            temp.clipsToBounds = true
            return temp
        }()
        
        private lazy var secondImageView:UIImageView = {
            let image = UIImage(named: "2")
            let temp = UIImageView(image:image)
            temp.contentMode = .scaleAspectFill
            temp.clipsToBounds = true
            return temp
        }()
        
        private lazy var thirthImageView:UIImageView = {
            let image = UIImage(named: "3")
            let temp = UIImageView(image:image)
            temp.contentMode = .scaleAspectFill
            temp.clipsToBounds = true
            return temp
        }()
    
    }

demo3 多个子视图不使用containerView布局

demo2的containerView包含多个子视图的布局方式相对来说好像使用要多一些,但是其实布局原理并没有任何变化,如果熟悉了UIScrollView的AutoLayout布局原理,用不用containerView大家可以根据情况自行决定,如果仅仅是简单的几个子视图布局没有特殊的需求那么直接布局可能会更简单,但是如果子视图相对较多并且可能所有子视图有公共的操作需求(例如所有子视图在键盘弹出后需要改变其位置)则更适合使用containerView布局。下面代码中去掉containerView完成demo2的需求,原理相同,代码也不难理解。

    class SlideViewController2: UIViewController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            self.automaticallyAdjustsScrollViewInsets = false
            
            self.view.addSubview(self.scrollView)
            self.scrollView.addSubview(self.firstImageView)
            self.scrollView.addSubview(self.secondImageView)
            self.scrollView.addSubview(self.thirthImageView)
            
            self.scrollView.snp.makeConstraints { (make) in
                make.top.equalTo(self.topLayoutGuide.snp.bottom)
                make.left.bottom.right.equalTo(0.0)
            }
    
            
            self.firstImageView.snp.makeConstraints { (make) in
                make.top.left.bottom.equalTo(0.0)
                make.size.equalTo(self.scrollView.snp.size)
            }
            
            self.secondImageView.snp.makeConstraints { (make) in
                make.top.bottom.equalTo(0.0)
                make.left.equalTo(self.firstImageView.snp.right)
                make.size.equalTo(self.scrollView.snp.size)
            }
            
            self.thirthImageView.snp.makeConstraints { (make) in
                make.top.bottom.equalTo(0.0)
                make.left.equalTo(self.secondImageView.snp.right)
                make.size.equalTo(self.scrollView.snp.size)
                make.right.equalTo(0.0) // 确定右边距
            }
            
        }
        
        // MARK: - 私有属性
        private lazy var scrollView:UIScrollView = {
            let temp = UIScrollView()
            temp.isPagingEnabled = true
            return temp
        }()
    
        private lazy var firstImageView:UIImageView = {
            let image = UIImage(named: "1")
            let temp = UIImageView(image:image)
            temp.contentMode = .scaleAspectFill
            temp.clipsToBounds = true
            return temp
        }()
        
        private lazy var secondImageView:UIImageView = {
            let image = UIImage(named: "2")
            let temp = UIImageView(image:image)
            temp.contentMode = .scaleAspectFill
            temp.clipsToBounds = true
            return temp
        }()
        
        private lazy var thirthImageView:UIImageView = {
            let image = UIImage(named: "3")
            let temp = UIImageView(image:image)
            temp.contentMode = .scaleAspectFill
            temp.clipsToBounds = true
            return temp
        }()
    
    }

最终效果

总结

其实概括起来UIScrollView的布局最主要的问题就是解决contentSize的计算问题。而根据UIScrollView的特点contentSize的计算最终就是根据上下左右边距和子控件自身尺寸来反向推导出来的。在遇到多个子视图的情况下具体用不用容器视图根据情况而定,容器视图仅仅起到辅助作用,整个布局原理是完全相同的。使用UIScrollView的AutoLayout布局优点自不必多说,除了从frame计算中摆脱出来之外(绝对布局和相对布局的区别),天生支持屏幕旋转(屏幕的旋转适配只需要在布局时稍加注意即可),例如上面三个demo均支持竖屏和横屏查看,相对于frame布局代码简化了很多。