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
'iOS > 예제' 카테고리의 다른 글
[iOS 예제] CircleProgressView 만들기 (0) | 2021.12.11 |
---|---|
[iOS 예제] UIView에 원형으로 shadow 넣기 (0) | 2021.12.07 |
[iOS 예제] Drag and Drop가능한 UIView만들기 (0) | 2021.12.05 |
[iOS 예제] UIPanGestureRecognizer로 BottomSheet 만들어보기 (0) | 2021.05.01 |
[iOS 예제] CircleProgress Shape 만들기 (0) | 2021.04.30 |
댓글