Skip to content

Instantly share code, notes, and snippets.

@k-o-d-e-n
Created January 15, 2018 13:11
Show Gist options
  • Save k-o-d-e-n/239314107fde6ba0859a529fb2068a9d to your computer and use it in GitHub Desktop.
Save k-o-d-e-n/239314107fde6ba0859a529fb2068a9d to your computer and use it in GitHub Desktop.
UIStackView + UIScrollView in single view. It has lazy loading of arranged views behaviour.
//
// ScrollStackView.swift
// iOS-Extensions
//
// Created by Denis Koryttsev on 07/08/2017.
// Copyright © 2017 Denis Koryttsev. All rights reserved.
//
import UIKit
/// This protocol represents data model for views
protocol ScrollStackViewDataSource: class {
func arrangedViewForScrollStackView(_ scrollStackView: ScrollStackView, at stackIndex: Int) -> UIView?
}
/// ScrollStackView is scrollable variant UIStackView.
@available(iOS 9.0, *)
class ScrollStackView: UIScrollView {
fileprivate weak var contentView: UIStackView!
fileprivate weak var axisConstraint: NSLayoutConstraint!
fileprivate var axisLayout: AxisLayout = .vertical
/// default nil. Weak reference on data source.
weak var dataSource: ScrollStackViewDataSource?
/// Value, which limit arranged views
var maximumContainedViews: Int = .max
/// Space to bottom of content size, indicated that need to load next view.
var loadingNextViewSpace: CGFloat = 100.0
/// default automatic dimension. Defines layout for arranged views.
var layout: Layout = .automaticDimension
override var bounds: CGRect {
set { super.bounds = newValue; loadNextViewIfNeeded() }
get { return super.bounds }
}
override init(frame: CGRect) {
super.init(frame: frame)
load()
addAxisConstraint()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
load()
}
override func awakeFromNib() {
super.awakeFromNib()
addAxisConstraint()
}
private func load() {
let contentView = UIStackView()
contentView.axis = .vertical
contentView.alignment = .fill
contentView.distribution = .fill
contentView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentView)
self.contentView = contentView
NSLayoutConstraint.activate([
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
contentView.topAnchor.constraint(equalTo: topAnchor)
])
}
/// Flag, indicating that scroll position placed in space for loading next view.
fileprivate var isOffsetInNextLoadingSpace: Bool {
return (contentOffset.y + frame.height) > (contentSize.height - loadingNextViewSpace)
}
@discardableResult
fileprivate func loadNextViewIfNeeded() -> UIView? {
guard arrangedSubviews.count < maximumContainedViews,
isOffsetInNextLoadingSpace,
let dataSource = dataSource,
let view = dataSource.arrangedViewForScrollStackView(self, at: arrangedSubviews.count)
else { return nil }
addArrangedSubview(view)
return view
}
/// Method for addition content constraint for avoid ambiguous content size.
fileprivate func addAxisConstraint() {
axisConstraint = axisLayout.axisConstraint(for: self)
axisConstraint.isActive = true
}
}
/// Protocol for implement axis layout.
fileprivate protocol ScrollStackViewAxisLayout {
/// Method for calculate constant of axis constraint
///
/// - Parameter stackView: View for which calculated
/// - Returns: Constant of axis constraint
func axisConstraintConstant(for stackView: ScrollStackView) -> CGFloat
/// Method for generation axis constraint
///
/// - Parameter stackView: View for which generated
/// - Returns: Axis constraint. Constraint is not active.
func axisConstraint(for stackView: ScrollStackView) -> NSLayoutConstraint
/// Method for generation constraints for arranged view.
///
/// - Parameters:
/// - view: Arranged view
/// - stackView: View contained arranged view.
/// - size: Size of arranged view.
/// - Returns: Constraints for received view. Constraints are not active.
func makeConstrains(for view: UIView, in stackView: ScrollStackView, size: CGSize) -> [NSLayoutConstraint]
}
/// Protocol for implement main layout
fileprivate protocol ScrollStackViewLayout {
/// Method is used for reload constraints and other layout things on transition to another axis
///
/// - Parameters:
/// - stackView: View where need to make transition
/// - axis: New axis layout
func performTransition(in stackView: ScrollStackView, to axis: ScrollStackViewAxisLayout)
/// Defined insertion process for new arranged view.
///
/// - Parameters:
/// - view: Arranged view.
/// - stackIndex: Target index for arranged view
/// - stackView: View where inserted view
/// - axis: Current axis layout
func insertArrangedSubview(_ view: UIView, at stackIndex: Int, to stackView: ScrollStackView, axis: ScrollStackViewAxisLayout)
}
extension ScrollStackView {
struct AxisLayout: ScrollStackViewAxisLayout {
fileprivate let base: ScrollStackViewAxisLayout
fileprivate init(base: ScrollStackViewAxisLayout) {
self.base = base
}
func axisConstraintConstant(for stackView: ScrollStackView) -> CGFloat {
return base.axisConstraintConstant(for: stackView)
}
func axisConstraint(for stackView: ScrollStackView) -> NSLayoutConstraint {
return base.axisConstraint(for: stackView)
}
func makeConstrains(for view: UIView, in stackView: ScrollStackView, size: CGSize) -> [NSLayoutConstraint] {
return base.makeConstrains(for: view, in: stackView, size: size)
}
static let horizontal = AxisLayout(base: Horizontal())
struct Horizontal: ScrollStackViewAxisLayout {
func axisConstraintConstant(for stackView: ScrollStackView) -> CGFloat {
return -(stackView.contentInset.bottom + stackView.contentInset.top)
}
func axisConstraint(for stackView: ScrollStackView) -> NSLayoutConstraint {
return stackView.contentView.heightAnchor.constraint(equalTo: stackView.heightAnchor, constant: axisConstraintConstant(for: stackView))
}
func makeConstrains(for view: UIView, in stackView: ScrollStackView, size: CGSize) -> [NSLayoutConstraint] {
return [view.heightAnchor.constraint(equalTo: stackView.contentView.heightAnchor),
view.widthAnchor.constraint(equalToConstant: size.width)]
}
}
static let vertical = AxisLayout(base: Vertical())
struct Vertical: ScrollStackViewAxisLayout {
func axisConstraintConstant(for stackView: ScrollStackView) -> CGFloat {
return -(stackView.contentInset.left + stackView.contentInset.right)
}
func axisConstraint(for stackView: ScrollStackView) -> NSLayoutConstraint {
return stackView.contentView.widthAnchor.constraint(equalTo: stackView.widthAnchor, constant: axisConstraintConstant(for: stackView))
}
func makeConstrains(for view: UIView, in stackView: ScrollStackView, size: CGSize) -> [NSLayoutConstraint] {
return [view.heightAnchor.constraint(equalToConstant: size.height),
view.widthAnchor.constraint(equalTo: stackView.contentView.widthAnchor)]
}
}
}
struct Layout: ScrollStackViewLayout {
fileprivate let base: ScrollStackViewLayout
fileprivate init(base: ScrollStackViewLayout) {
self.base = base
}
fileprivate func performTransition(in stackView: ScrollStackView, to axis: ScrollStackViewAxisLayout) {
base.performTransition(in: stackView, to: axis)
}
fileprivate func insertArrangedSubview(_ view: UIView, at stackIndex: Int, to stackView: ScrollStackView, axis: ScrollStackViewAxisLayout) {
base.insertArrangedSubview(view, at: stackIndex, to: stackView, axis: axis)
}
static let automaticDimension = Layout(base: AutomaticDimension())
struct AutomaticDimension: ScrollStackViewLayout {
fileprivate func performTransition(in stackView: ScrollStackView, to axis: ScrollStackViewAxisLayout) {}
fileprivate func insertArrangedSubview(_ view: UIView, at stackIndex: Int, to stackView: ScrollStackView, axis: ScrollStackViewAxisLayout) {
stackView.contentView.insertArrangedSubview(view, at: stackIndex)
}
}
static let frameBased = Layout(base: FrameBased())
struct FrameBased: ScrollStackViewLayout {
fileprivate func performTransition(in stackView: ScrollStackView, to axis: ScrollStackViewAxisLayout) {
stackView.arrangedSubviews.forEach { view in
NSLayoutConstraint.deactivate(view.constraints.filter {
return ($0.secondItem.map { s in s as! NSObject == stackView.contentView } ?? true || $0.secondAttribute == .notAnAttribute)
&& ($0.firstAttribute == .width || $0.firstAttribute == .height)
})
view.removeFromSuperview()
insertArrangedSubview(view, at: stackView.arrangedSubviews.count, to: stackView, axis: axis)
}
}
fileprivate func insertArrangedSubview(_ view: UIView, at stackIndex: Int, to stackView: ScrollStackView, axis: ScrollStackViewAxisLayout) {
view.translatesAutoresizingMaskIntoConstraints = false
stackView.contentView.insertArrangedSubview(view, at: stackIndex)
NSLayoutConstraint.activate(axis.makeConstrains(for: view, in: stackView, size: view.frame.size))
}
}
}
}
// MARK: Public
extension ScrollStackView {
override var contentInset: UIEdgeInsets {
set {
super.contentInset = newValue
axisConstraint.constant = axisLayout.axisConstraintConstant(for: self)
}
get { return super.contentInset }
}
var arrangedSubviews: [UIView] { return contentView.arrangedSubviews }
/// Current axis layout
var axis: UILayoutConstraintAxis {
set {
let oldValue = axis
if oldValue != newValue {
axisLayout = newValue == .horizontal ? .horizontal : .vertical
layout.performTransition(in: self, to: axisLayout)
let oldConstraint = axisConstraint
addAxisConstraint()
oldConstraint?.isActive = false
contentView.axis = newValue
}
}
get { return contentView.axis }
}
/// Adds view to end of content
///
/// - Parameter view: View for addition
func addArrangedSubview(_ view: UIView) {
insertArrangedSubview(view, at: arrangedSubviews.count)
}
/// Insert view to defined index.
///
/// - Parameters:
/// - view: View for insertion.
/// - stackIndex: Index position.
func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
layout.insertArrangedSubview(view, at: stackIndex, to: self, axis: axisLayout)
}
/// Removes view from arranged view without remove from subviews hierarchy.
///
/// - Parameter view: Removed view.
func removeArrangedSubview(_ view: UIView) {
contentView.removeArrangedSubview(view)
}
/// Force load all views which not loaded.
func loadContent() {
guard dataSource != nil else { return }
while arrangedSubviews.count < maximumContainedViews, isOffsetInNextLoadingSpace, let view = loadNextViewIfNeeded() {
view.layoutIfNeeded()
contentSize.height += view.frame.height
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment