Created
January 15, 2018 13:11
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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