darrarski/ScrollViewController

View on GitHub
Sources/ScrollWrapperView.swift

Summary

Maintainability
A
0 mins
Test Coverage
import UIKit

/// `UIScrollView` wrapper that allows configuring how the scrollable content is laid out.
public class ScrollWrapperView: UIView {
    /// Create `UIScrollView` wrapper view.
    public init() {
        scrollView = UIScrollView(frame: .zero)
        super.init(frame: .zero)
        scrollView.keyboardDismissMode = .interactive
        addSubview(scrollView)
        scrollView.addSubview(contentWrapperView)
        setupLayout()
    }

    /// Does nothing, this class is designed to be used programmatically.
    required public init?(coder aDecoder: NSCoder) { nil }

    // MARK: - Subviews

    /// Wrapped `UIScrollView`.
    public let scrollView: UIScrollView

    /// Scrollable content view.
    public var contentView: UIView? {
        didSet {
            oldValue?.removeFromSuperview()
            contentViewTopEqualSuper = nil
            contentViewTopGreaterThanSuper = nil
            contentViewLeft = nil
            contentViewRight = nil
            contentViewBottom = nil
            if let newValue = contentView {
                contentWrapperView.addSubview(newValue)
                setupLayout(contentView: newValue)
            }
        }
    }

    let contentWrapperView = UIView()

    // MARK: - Layout configuration

    /// If `true`, `contentView` will be stretched to fill visible area.
    ///
    /// Default is `true`.
    public var contentViewStretching = true {
        didSet { contentWrapperHeight.isActive = contentViewStretching }
    }

    /// If `true` the content view will be aligned to the bottom of scrollable area.
    ///
    /// Default is `false`.
    ///
    /// If the `contentViewStretching` is set to `false` this property makes no changes to the alignemnt.
    public var alignContentToBottom = false {
        didSet {
            contentViewTopGreaterThanSuper?.isActive = alignContentToBottom == true
            contentViewTopEqualSuper?.isActive = alignContentToBottom == false
        }
    }

    /// Scrollable content insets.
    ///
    /// Default is `.zero` which means no insets.
    public var contentInsets: UIEdgeInsets = .zero {
        didSet {
            contentViewTopEqualSuper?.constant = contentInsets.top
            contentViewTopGreaterThanSuper?.constant = contentInsets.top
            contentViewLeft?.constant = contentInsets.left
            contentViewRight?.constant = -contentInsets.right
            contentViewBottom?.constant = -contentInsets.bottom
        }
    }

    // MARK: - Touch handling configuration

    /// If `true` touches outside the `contentView` will be handled and allow scrolling.
    ///
    /// Default is `true`.
    public var handlesTouchesOutsideContent = true

    public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if handlesTouchesOutsideContent {
            return super.hitTest(point, with: event)
        }
        if let contentView = contentView, contentView.bounds.contains(convert(point, to: contentView)) {
            return super.hitTest(point, with: event)
        }
        return nil
    }

    // MARK: - Internals

    var visibleContentInsets: UIEdgeInsets {
        get {
            UIEdgeInsets(
                top: visibleContentLayoutGuideTop.constant,
                left: visibleContentLayoutGuideLeft.constant,
                bottom: -visibleContentLayoutGuideBottom.constant,
                right: -visibleContentLayoutGuideRight.constant
            )
        }
        set {
            visibleContentLayoutGuideTop.constant = newValue.top
            visibleContentLayoutGuideLeft.constant = newValue.left
            visibleContentLayoutGuideRight.constant = -newValue.right
            visibleContentLayoutGuideBottom.constant = -newValue.bottom
        }
    }

    private let visibleContentLayoutGuide = UILayoutGuide()
    private var visibleContentLayoutGuideTop: NSLayoutConstraint!
    private var visibleContentLayoutGuideLeft: NSLayoutConstraint!
    private var visibleContentLayoutGuideRight: NSLayoutConstraint!
    private var visibleContentLayoutGuideBottom: NSLayoutConstraint!
    private var contentWrapperHeight: NSLayoutConstraint!
    private var contentViewTopEqualSuper: NSLayoutConstraint?
    private var contentViewTopGreaterThanSuper: NSLayoutConstraint?
    private var contentViewLeft: NSLayoutConstraint?
    private var contentViewRight: NSLayoutConstraint?
    private var contentViewBottom: NSLayoutConstraint?

    private func setupLayout() {
        setupVisibleContentLayoutGuide()
        setupScrollViewLayout()
        setupContentWrapperViewLayout()
    }

    private func setupScrollViewLayout() {
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.topAnchor.constraint(equalTo: topAnchor).isActive = true
        scrollView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
        scrollView.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
        scrollView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
    }

    private func setupContentWrapperViewLayout() {
        contentWrapperView.translatesAutoresizingMaskIntoConstraints = false
        contentWrapperView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        contentWrapperView.leftAnchor.constraint(equalTo: scrollView.leftAnchor).isActive = true
        contentWrapperView.rightAnchor.constraint(equalTo: scrollView.rightAnchor).isActive = true
        contentWrapperView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
        contentWrapperView.widthAnchor.constraint(equalTo: scrollView.widthAnchor).isActive = true
        contentWrapperHeight = contentWrapperView.heightAnchor.constraint(
            greaterThanOrEqualTo: visibleContentLayoutGuide.heightAnchor
        )
        contentWrapperHeight.isActive = contentViewStretching
    }

    private func setupVisibleContentLayoutGuide() {
        addLayoutGuide(visibleContentLayoutGuide)

        visibleContentLayoutGuideTop = visibleContentLayoutGuide.topAnchor.constraint(equalTo: topAnchor)
        visibleContentLayoutGuideTop.isActive = true

        visibleContentLayoutGuideLeft = visibleContentLayoutGuide.leftAnchor.constraint(equalTo: leftAnchor)
        visibleContentLayoutGuideLeft.isActive = true

        visibleContentLayoutGuideRight = visibleContentLayoutGuide.rightAnchor.constraint(equalTo: rightAnchor)
        visibleContentLayoutGuideRight.priority = .defaultHigh
        visibleContentLayoutGuideRight.isActive = true

        visibleContentLayoutGuideBottom = visibleContentLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor)
        visibleContentLayoutGuideBottom.priority = .defaultHigh
        visibleContentLayoutGuideBottom.isActive = true
    }

    private func setupLayout(contentView view: UIView) {
        view.translatesAutoresizingMaskIntoConstraints = false

        contentViewTopEqualSuper = view.topAnchor.constraint(equalTo: contentWrapperView.topAnchor)
        contentViewTopEqualSuper?.constant = contentInsets.top
        contentViewTopEqualSuper?.isActive = alignContentToBottom == false

        contentViewTopGreaterThanSuper = view.topAnchor.constraint(greaterThanOrEqualTo: contentWrapperView.topAnchor)
        contentViewTopGreaterThanSuper?.constant = contentInsets.top
        contentViewTopGreaterThanSuper?.isActive = alignContentToBottom == true

        contentViewLeft = view.leftAnchor.constraint(equalTo: contentWrapperView.leftAnchor)
        contentViewLeft?.constant = contentInsets.left
        contentViewLeft?.isActive = true

        contentViewRight = view.rightAnchor.constraint(equalTo: contentWrapperView.rightAnchor)
        contentViewRight?.constant = -contentInsets.right
        contentViewRight?.isActive = true

        contentViewBottom = view.bottomAnchor.constraint(equalTo: contentWrapperView.bottomAnchor)
        contentViewBottom?.constant = -contentInsets.bottom
        contentViewBottom?.isActive = true
    }
}