Skip to content

Instantly share code, notes, and snippets.

@audrl1010
Last active May 15, 2024 07:09
Show Gist options
  • Save audrl1010/dd41f7d5910f3e4ead747ea73c5f6a6e to your computer and use it in GitHub Desktop.
Save audrl1010/dd41f7d5910f3e4ead747ea73c5f6a6e to your computer and use it in GitHub Desktop.
SwiftUI LazyStack 처럼, UIKit 용도의 Lazy Display Stack ScrollView 구현

LazyDisplayStackScrollView

What?

  • ScrollView에 StackView를 감싸서, 많은 양의 subviews들을 처음에 추가할 때 rendering 과부하가 걸리게 됩니다. scrolling시 화면에 보이는 영역의 subviews만 render할 수 있다면 굉장히 퍼포먼스가 좋을 것입니다. SwiftUI에서는 이러한 문제점을 인식했는 지 LazyStack이라는 것을 제공합니다. 하지만 UIKit에서는 그러한 것을 제공하지 않습니다. 이 라이브러리는 해당 이슈를 해결하기 위해 만들어졌습니다.

Description

  • on-demand subviews render를 지원합니다.
  • Note) subview(LazyDisplayView)를 추가할 때 estimatedHeight(추측 높이)값을 대략적으로 정확히 설정해주지 않으면, LazyDisplayStackScrollView가 좋은 성능을 내기 힘듭니다.

Implement

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

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)
    }
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment