# LazyDisplayStackScrollView ## What? - ScrollView에 StackView를 감싸서, 많은 양의 subviews들을 처음에 추가할 때 rendering 과부하가 걸리게 됩니다. scrolling시 화면에 보이는 영역의 subviews만 render할 수 있다면 굉장히 퍼포먼스가 좋을 것입니다. SwiftUI에서는 이러한 문제점을 인식했는 지 LazyStack이라는 것을 제공합니다. 하지만 UIKit에서는 그러한 것을 제공하지 않습니다. 이 라이브러리는 해당 이슈를 해결하기 위해 만들어졌습니다. ## Description - on-demand subviews render를 지원합니다. - Note) subview(LazyDisplayView)를 추가할 때 estimatedHeight(추측 높이)값을 대략적으로 정확히 설정해주지 않으면, LazyDisplayStackScrollView가 좋은 성능을 내기 힘듭니다. ## Implement ```swift class LazyDisplayStackScrollView: UIView { // 위 아래, 허용 var distance: CGFloat = 100 private(set) var lazyDisplayViews: [LazyDisplayView] = [] private lazy var scrollView = UIScrollView() private let contentStack = UIStackView().then { $0.axis = .vertical $0.distribution = .fill $0.alignment = .fill } private var scrollViewBoundsToken: NSKeyValueObservation? private var scrollViewContentSizeToken: NSKeyValueObservation? deinit { self.scrollViewBoundsToken?.invalidate() self.scrollViewContentSizeToken?.invalidate() } override init(frame: CGRect) { super.init(frame: frame) self.setup() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setup() { self.scrollViewBoundsToken = self.scrollView.observe(\UIScrollView.bounds, options: [.new]) { [weak self] scrollView, change in guard let self = self else { return } self.displayPendingViewsIfNeeded() } self.scrollViewContentSizeToken = self.scrollView.observe(\UIScrollView.contentSize, options: [.new]) { [weak self] scrollView, change in guard let self = self else { return } self.displayPendingViewsIfNeeded() } self.addSubview(self.scrollView) self.scrollView.snp.makeConstraints { $0.edges.equalToSuperview() } self.scrollView.addSubview(self.contentStack) self.contentStack.snp.makeConstraints { $0.edges.equalToSuperview() $0.width.equalToSuperview() } } func addArrangedSubview(_ view: LazyDisplayView) { self.lazyDisplayViews.append(view) self.contentStack.addArrangedSubview(view) self.contentStack.setNeedsLayout() self.contentStack.layoutIfNeeded() } func insertArrangedSubview(_ view: LazyDisplayView, at index: Int) { self.lazyDisplayViews.insert(view, at: index) self.contentStack.insertArrangedSubview(view, at: index) self.scrollView.setNeedsLayout() self.scrollView.layoutIfNeeded() } func removeArrangedSubview(_ view: LazyDisplayView) { let index = self.lazyDisplayViews.firstIndex { $0 === view } if let index = index { self.lazyDisplayViews.remove(at: index) self.contentStack.removeArrangedSubview(view) view.removeFromSuperview() self.contentStack.setNeedsLayout() self.contentStack.layoutIfNeeded() } } func removeAllArrangedSubviews() { self.lazyDisplayViews.removeAll() for subview in self.contentStack.arrangedSubviews { self.contentStack.removeArrangedSubview(subview) subview.removeFromSuperview() } self.contentStack.setNeedsLayout() self.contentStack.layoutIfNeeded() } private func displayPendingViewsIfNeeded() { let topPendingRect = CGRect( origin: .init(x: self.scrollView.contentOffset.x, y: self.scrollView.contentOffset.y - self.distance), size: self.scrollView.frame.size ) let bottomPendingRect = CGRect( origin: .init(x: self.scrollView.contentOffset.x, y: self.scrollView.contentOffset.y + self.distance), size: self.scrollView.frame.size ) for lazyDisplayView in self.lazyDisplayViews { if lazyDisplayView.isPending { if topPendingRect.intersects(lazyDisplayView.frame) || bottomPendingRect.intersects(lazyDisplayView.frame) { lazyDisplayView.isPending = false lazyDisplayView.display() } } } } } class LazyDisplayView: UIView { fileprivate(set) var isPending: Bool = true private(set) var estimatedHeight: CGFloat = 50 override init(frame: CGRect) { super.init(frame: frame) self.setup() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setup() { self.snp.makeConstraints { $0.height.equalTo(self.estimatedHeight) } } // Override 해서 사용하세요. func display() { // 기본 estimated height 설정 삭제 self.snp.remakeConstraints { _ in } } func setEstimatedHeight(_ height: CGFloat) { self.estimatedHeight = height self.snp.updateConstraints { $0.height.equalTo(height) } } } ``` ## Example ```swift class ViewController: UIViewController { private let contentView = LazyDisplayStackScrollView() override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .white self.view.addSubview(self.contentView) self.contentView.snp.makeConstraints { $0.top.equalTo(self.view.safeAreaLayoutGuide) $0.left.right.bottom.equalToSuperview() } let item0 = ItemView().then { $0.backgroundColor = .gray $0.label.text = "0" $0.tag = 0 } let item1 = ItemView().then { $0.backgroundColor = .gray $0.label.text = "1" $0.tag = 1 } let item2 = ItemView().then { $0.backgroundColor = .gray $0.label.text = "2" $0.tag = 3 } let item5 = ItemView().then { $0.backgroundColor = .gray $0.label.text = "3" $0.tag = 5 } self.contentView.addArrangedSubview(item0) self.contentView.addArrangedSubview(item1) self.contentView.addArrangedSubview(item2) self.contentView.addArrangedSubview(item5) DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [unowned self] in self.contentView.insertArrangedSubview(ItemView().then { $0.backgroundColor = .gray $0.label.text = "11" $0.tag = 11 }, at: 1) } } } import UIKit import SnapKit import Then class ItemView: LazyDisplayView { let label = UILabel() let stack = UIStackView().then { $0.axis = .vertical $0.alignment = .fill $0.distribution = .fill } let button = UIButton().then { $0.setTitle("Toggle", for: .normal) } override init(frame: CGRect) { super.init(frame: frame) self.setup() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setup() { self.setEstimatedHeight(500) } override func display() { super.display() self.layer.borderColor = UIColor.red.cgColor self.layer.borderWidth = 1.0 print("display ", self.tag) self.button.setTitle("\(self.tag)", for: .normal) self.addSubview(self.button) self.button.addTarget(self, action: #selector(didTap), for: .touchUpInside) self.button.snp.makeConstraints { $0.height.equalTo(500) $0.edges.equalToSuperview() } } var isToggle: Bool = false @objc func didTap() { let generator = UIImpactFeedbackGenerator(style: .light) generator.impactOccurred() self.isToggle = !self.isToggle self.button.snp.updateConstraints { $0.height.equalTo(self.isToggle ? 1000 : 250) } } } ```