Skip to content

Instantly share code, notes, and snippets.

@audrl1010
Last active May 15, 2024 07:09
Show Gist options
  • Select an option

  • Save audrl1010/dd41f7d5910f3e4ead747ea73c5f6a6e to your computer and use it in GitHub Desktop.

Select an option

Save audrl1010/dd41f7d5910f3e4ead747ea73c5f6a6e to your computer and use it in GitHub Desktop.
SwiftUI LazyStack 처럼, UIKit 용도의 Lazy Display Stack ScrollView 구현
// LPLazyDisplayStackScrollView는 on-demand subviews render를 지원합니다.
// Note) addArrangedSubview 하려는 LPLazyDisplayView의 estimatedHeight(추측 높이)값을 대략적으로 정확히 설정해주지 않으면,
// LPLazyDisplayStackScrollView가 좋은 성능을 내기 힘듭니다.

class LPLazyDisplayStackScrollView: UIView {
  
  var distance: CGFloat = 100
  
  private(set) var lazyDisplayViews: [LPLazyDisplayView] = []
  
  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: LPLazyDisplayView) {
    self.lazyDisplayViews.append(view)
    self.contentStack.addArrangedSubview(view)
    self.contentStack.setNeedsLayout()
    self.contentStack.layoutIfNeeded()
  }
  
  func insertArrangedSubview(_ view: LPLazyDisplayView, at index: Int) {
    self.lazyDisplayViews.insert(view, at: index)
    self.contentStack.insertArrangedSubview(view, at: index)
    self.scrollView.setNeedsLayout()
    self.scrollView.layoutIfNeeded()
  }
  
  func removeArrangedSubview(_ view: LPLazyDisplayView) {
    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 LPLazyDisplayView: 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 = LPLazyDisplayStackScrollView()
  
  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)
    }
    
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment