본문 바로가기
iOS/예제

[iOS 예제] InfiniteTextView 무한 스크롤 텍스트뷰 만들기

by Sky Titan 2021. 6. 17.
728x90

InfiniteTextView 무한 스크롤 텍스트뷰 만들기

  • UIScrollView와 UIStackView의 조합

import UIKit

class InfiniteTextView: UIView {
    
    lazy var innerView: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()
    lazy var scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.showsVerticalScrollIndicator = false
        scrollView.isScrollEnabled = false
        return scrollView
    }()
    
    //MARK: - IBInspectable
    @IBInspectable var text: String = ""
    @IBInspectable var fontSize: CGFloat = 0
    @IBInspectable var textColor: UIColor = .black
    @IBInspectable var speed: Int = 1
    @IBInspectable var margin: CGFloat = 0 {
        didSet {
            innerView.spacing = margin
        }
    }
    
    private var labelList: [UILabel] = []
    private var timer: Timer? = nil
    
    //MARK: - flags
    var isAutoScrolling: Bool {
        if let timer = timer, timer.isValid {
            return true
        }
        return false
    }
    var isScrollEnabled: Bool {
        set {
            scrollView.isScrollEnabled = newValue
        }
        get {
            return scrollView.isScrollEnabled
        }
    }
    var needAutoScroll: Bool {
        return blockSize > scrollView.frame.size.width
    }
    
    var attributedText: NSAttributedString?
    
    //MARK: - constants
    private var labelWidth: CGFloat {
        if let attributedText = attributedText {
            return attributedText.width(withConstrainedHeight: 0)
        } else {
            return text.width(withConstrainedHeight: 0, font: UIFont.systemFont(ofSize: fontSize))
        }
    }
    private var blockSize: CGFloat {
        return labelWidth + margin
    }
    
    
    //MARK: - init
    override init(frame: CGRect) {
        super.init(frame: frame)
        initialize()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        initialize()
    }
    
    private func initialize() {
        setConstraints()
        scrollView.delegate = self
    }
    
    private func setConstraints() {
        addSubview(scrollView)
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true
        scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true
        scrollView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
        scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
        
        scrollView.addSubview(innerView)
        innerView.translatesAutoresizingMaskIntoConstraints = false
        innerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor).isActive = true
        innerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
        innerView.topAnchor.constraint(equalTo: scrollView.topAnchor).isActive = true
        innerView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
        
        innerView.heightAnchor.constraint(equalTo: scrollView.heightAnchor).isActive = true
    }
    
    deinit {
        stopScrollIfNeeded()
    }
    
    //MARK: - Scroll
    func startScrollIfNeeded() {
        if needAutoScroll, !isAutoScrolling {
            scrollToOrigin()
            startAutoScroll()
        }
    }
    
    func stopScrollIfNeeded() {
        if isAutoScrolling {
            scrollToOrigin()
            stopAutoScroll()
            reset()
        }
    }
    
    private func startAutoScroll() {
        timer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(scrollMove), userInfo: nil, repeats: true)
    }
    
    private func stopAutoScroll() {
        timer?.invalidate()
        timer = nil
    }
    
    @objc func scrollMove() {
        for _ in 0 ..< speed {
            scrollView.contentOffset.x += 1
        }
        
    }
    
    private func scrollToOrigin() {
        scrollView.setContentOffset(.zero, animated: false)
    }
    
    func reset() {
        labelList.forEach { label in
            label.removeFromSuperview()
        }
        labelList.removeAll()
        addLabel()
    }
    
    //MARK: - control Label
    private func addLabel() {
        let index = labelList.count
        let label = createLabel(index: index)
        labelList.append(label)
        innerView.addArrangedSubview(label)
    }
    
    private func removeLabel() {
        labelList.first?.removeFromSuperview()
        labelList.removeFirst()
    }
    
    private func createLabel(index: Int) -> UILabel {
        let label = UILabel(frame: CGRect(x: 0, y: 0, width: labelWidth, height: frame.size.height))
        label.textColor = textColor
        label.font = UIFont.systemFont(ofSize: fontSize)
        
        if let attributedText = attributedText {
            label.attributedText = attributedText
        } else {
            label.text = text
        }
        
        return label
    }
   
}

extension InfiniteTextView: UIScrollViewDelegate {
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if isAutoScrolling {
            //MARK: - Auto scroll
            let needAdd: Bool = scrollView.contentOffset.x + scrollView.frame.size.width == blockSize
            let needRemove: Bool = scrollView.contentOffset.x == blockSize
            
            //추가
            if needAdd {
                addLabel()
            }
            
            //삭제
            if needRemove {
                removeLabel()
                scrollView.contentOffset.x -= blockSize
            }
        }
    }
}


//MARK: - Extensions
extension Collection {

    /// Returns the element at the specified index if it is within bounds, otherwise nil.
    subscript (safe index: Index) -> Element? {
        return indices.contains(index) ? self[index] : nil
    }
}

extension String {
    func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
    
        return ceil(boundingBox.height)
    }

    func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
        let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
        let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)

        return ceil(boundingBox.width)
    }
}

extension NSAttributedString {
    func height(withConstrainedWidth width: CGFloat) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil)
    
        return ceil(boundingBox.height)
    }

    func width(withConstrainedHeight height: CGFloat) -> CGFloat {
        let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
        let boundingBox = boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil)
    
        return ceil(boundingBox.width)
    }
}

extension Int {
    var cgFloat: CGFloat {
        return CGFloat(self)
    }
}



import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var infiniteTextView: InfiniteTextView!
    @IBOutlet weak var speedSlider: UISlider!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        infiniteTextView.reset()
    }

    @IBAction func startButtonClicked(_ sender: Any) {
        infiniteTextView.startScrollIfNeeded()
    }
    
    @IBAction func stopButtonClicked(_ sender: Any) {
        infiniteTextView.stopScrollIfNeeded()
    }
    @IBAction func speedChanged(_ sender: Any) {
        infiniteTextView.speed = Int(speedSlider.value)
    }
}

 

 

이 기능을 꼭 만들어보고 싶었던 터라 시간날 때 짬내서 만들어봤다.

쉬울거라고 생각하진 않았지만 생각보다 이슈가 많이 생겼고 그 과정에서 내가 몰랐던 것들을 많이 알게 되었다.

아직 많이 부족한 것 같다... ㅠ

 

기본적으로 UIScrollView와 UIStackView를 조합해서 사용한다.

UIScrollView의 베이스 역할을 하는 스택뷰를 넣고 스택뷰 안에 UILabel을 넣는다.

그리고 auto scroll 중 화면에서 사라지는 label은 바로 스택뷰에서 제거하고, 새 Label을 추가해야되는 타이밍을 계산해서 Label을 추가해서 마치 계속 순환하면서 보여지고 있는 듯이 만들었다.

 

Label을 지우지 않고 무한히 생성만 하면 당연히 메모리에 문제가 생길 것이기에 Label을 제거하고 StackView의 크기가 자동으로 줄어들면서 뒤에 추가되어있던 Label이 앞으로 올 때, 한 block 크기 정도 앞으로 scroll을 해서 일종의 착시(?)현상을 주었다.

 

말로 설명하니까 어렵네...

 

 

아직 수정하고 추가해야될 부분들이 많다. 프로토타입 수준이고 코드는 계속 수정해나갈 예정이다.

728x90

댓글