Skip to content

Instantly share code, notes, and snippets.

@uy
Created July 28, 2026 06:09
Show Gist options
  • Select an option

  • Save uy/a88acdcb6f519ef34c1e2a6097e5b409 to your computer and use it in GitHub Desktop.

Select an option

Save uy/a88acdcb6f519ef34c1e2a6097e5b409 to your computer and use it in GitHub Desktop.
scroll header
//
// UYDefaultRefreshContentView.swift
// app-refresh-test2
//
// Üç görsel panel içerir:
// • pullingPanel — çekme animasyonu (ok ikonu)
// • mainPanel — loading (.refreshing) + post-loading (.postLoading)
// • cooldownPanel — geri sayım (.cooldown)
//
import UIKit
// MARK: - UYDefaultRefreshContentView
public final class UYDefaultRefreshContentView: UIView, UYRefreshContentView {
// MARK: - Configuration
public struct Configuration {
/// Loading ve post-loading panelinde gösterilecek başlık.
public var title: String
/// Loading animasyonu view'ı (örn. LottieAnimationView).
/// `nil` ise UIActivityIndicatorView kullanılır.
public var loadingAnimationView: (any UYLoadingAnimatable)?
/// "Son güncelleme" etiketi için format.
public var dateFormatter: DateFormatter
public init(
title: String,
loadingAnimationView: (any UYLoadingAnimatable)? = nil,
dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .short
return f
}()
) {
self.title = title
self.loadingAnimationView = loadingAnimationView
self.dateFormatter = dateFormatter
}
}
// MARK: - Public
/// Son güncelleme zamanı. Dışarıdan set edilebilir; `.ending` sonrası otomatik güncellenir.
public var lastUpdateTime: Date? { didSet { refreshTimestampLabels() } }
/// Cooldown sıfıra indiğinde çağrılır. Gerekirse dışarıda state güncellenir.
public var onCooldownEnd: (() -> Void)?
// MARK: - Private — state
private let configuration: Configuration
private var cooldownTimer: Timer?
private var cooldownSecondsLeft: Int = 0
// MARK: - Private — Main panel (loading + postLoading)
private let mainPanel = UIView()
private lazy var activeLoadingView: any UYLoadingAnimatable = {
configuration.loadingAnimationView ?? {
let ai = UIActivityIndicatorView(style: .medium)
ai.hidesWhenStopped = false
return ai
}()
}()
private let checkmarkImageView: UIImageView = {
let cfg = UIImage.SymbolConfiguration(pointSize: 22, weight: .bold)
let iv = UIImageView(image: UIImage(systemName: "checkmark", withConfiguration: cfg))
iv.tintColor = .systemGreen
iv.contentMode = .scaleAspectFit
return iv
}()
private let mainTitleLabel: UILabel = {
let lbl = UILabel()
lbl.font = .systemFont(ofSize: 15, weight: .bold)
lbl.textColor = .label
lbl.textAlignment = .center
lbl.numberOfLines = 1
return lbl
}()
private let mainTimestampLabel: UILabel = {
let lbl = UILabel()
lbl.font = .systemFont(ofSize: 12, weight: .regular)
lbl.textColor = .secondaryLabel
lbl.textAlignment = .center
return lbl
}()
// MARK: - Private — Cooldown panel
private let cooldownPanel = UIView()
private let cooldownTimestampLabel: UILabel = {
let lbl = UILabel()
lbl.font = .systemFont(ofSize: 12, weight: .regular)
lbl.textColor = .secondaryLabel
lbl.textAlignment = .center
return lbl
}()
private let cooldownCountdownLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.numberOfLines = 1
return lbl
}()
// MARK: - Init
public init(configuration: Configuration, lastUpdateTime: Date? = nil) {
self.configuration = configuration
self.lastUpdateTime = lastUpdateTime
super.init(frame: .zero)
setupLayout()
refreshTimestampLabels()
setState(.pulling(progress: 0))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - UYRefreshContentView
public func setState(_ state: UYRefreshState) {
switch state {
case .pulling(let progress):
showOnly(mainPanel)
checkmarkImageView.isHidden = true
activeLoadingView.isHidden = false
activeLoadingView.stopAnimating()
activeLoadingView.setProgress(progress)
case .refreshing:
showOnly(mainPanel)
checkmarkImageView.isHidden = true
activeLoadingView.isHidden = false
activeLoadingView.startAnimating()
case .postLoading:
activeLoadingView.stopAnimating()
showOnly(mainPanel)
activeLoadingView.isHidden = true
checkmarkImageView.isHidden = false
lastUpdateTime = Date()
case .cooldown(let seconds):
// Timer UYRefreshControl tarafından yönetilir.
// View sadece gelen değeri gösterir.
activeLoadingView.stopAnimating()
showOnly(cooldownPanel)
updateCooldownLabel(seconds: seconds)
case .ending:
activeLoadingView.stopAnimating()
showOnly(mainPanel)
}
}
// MARK: - Layout
private func setupLayout() {
backgroundColor = .clear
mainTitleLabel.text = configuration.title
// ── Main panel ──
let loadingV = activeLoadingView
loadingV.translatesAutoresizingMaskIntoConstraints = false
checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false
// Icon container (loading OR checkmark, same position)
let iconContainer = UIView()
iconContainer.translatesAutoresizingMaskIntoConstraints = false
iconContainer.addSubview(loadingV)
iconContainer.addSubview(checkmarkImageView)
NSLayoutConstraint.activate([
loadingV.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
loadingV.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
loadingV.widthAnchor.constraint(equalToConstant: 28),
loadingV.heightAnchor.constraint(equalToConstant: 28),
checkmarkImageView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
checkmarkImageView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
checkmarkImageView.widthAnchor.constraint(equalToConstant: 28),
checkmarkImageView.heightAnchor.constraint(equalToConstant: 28),
iconContainer.widthAnchor.constraint(equalToConstant: 28),
iconContainer.heightAnchor.constraint(equalToConstant: 28)
])
let mainStack = UIStackView(arrangedSubviews: [iconContainer, mainTitleLabel, mainTimestampLabel])
mainStack.axis = .vertical
mainStack.alignment = .center
mainStack.spacing = 4
mainStack.translatesAutoresizingMaskIntoConstraints = false
mainPanel.addSubview(mainStack)
NSLayoutConstraint.activate([
mainStack.centerXAnchor.constraint(equalTo: mainPanel.centerXAnchor),
mainStack.centerYAnchor.constraint(equalTo: mainPanel.centerYAnchor)
])
// ── Cooldown panel ──
let cooldownStack = UIStackView(arrangedSubviews: [cooldownTimestampLabel, cooldownCountdownLabel])
cooldownStack.axis = .vertical
cooldownStack.alignment = .center
cooldownStack.spacing = 2
cooldownStack.translatesAutoresizingMaskIntoConstraints = false
cooldownPanel.addSubview(cooldownStack)
NSLayoutConstraint.activate([
cooldownStack.centerXAnchor.constraint(equalTo: cooldownPanel.centerXAnchor),
cooldownStack.centerYAnchor.constraint(equalTo: cooldownPanel.centerYAnchor)
])
// ── Add all panels ──
[mainPanel, cooldownPanel].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
addSubview($0)
NSLayoutConstraint.activate([
$0.leadingAnchor.constraint(equalTo: leadingAnchor),
$0.trailingAnchor.constraint(equalTo: trailingAnchor),
$0.topAnchor.constraint(equalTo: topAnchor),
$0.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
}
// MARK: - Helpers
private func showOnly(_ panel: UIView) {
for p in [mainPanel, cooldownPanel] {
p.isHidden = p !== panel
}
}
private func refreshTimestampLabels() {
let text: String? = lastUpdateTime.map { "Son güncelleme: \(configuration.dateFormatter.string(from: $0))" }
mainTimestampLabel.text = text
cooldownTimestampLabel.text = text
}
private func updateCooldownLabel(seconds: Int) {
let boldAttr: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14, weight: .bold),
.foregroundColor: UIColor.label
]
let regularAttr: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14, weight: .regular),
.foregroundColor: UIColor.label
]
let str = NSMutableAttributedString(string: "\(seconds)", attributes: boldAttr)
str.append(NSAttributedString(string: " sn sonra güncelleyebilirsiniz", attributes: regularAttr))
cooldownCountdownLabel.attributedText = str
}
}
//
// UYRefreshContentView.swift
// app-refresh-test2
//
import UIKit
// MARK: - UYLoadingAnimatable
/// Lottie veya herhangi bir loading animasyonu view'ının uyması gereken protokol.
/// `LottieAnimationView` için: `extension LottieAnimationView: UYLoadingAnimatable {}`
public protocol UYLoadingAnimatable: UIView {
func startAnimating()
func stopAnimating()
func setProgress(_ progress: CGFloat)
}
extension UIActivityIndicatorView: UYLoadingAnimatable {}
// MARK: - UYRefreshState
public enum UYRefreshState {
/// Kullanıcı çekiyor; progress 0 → 1.
case pulling(progress: CGFloat)
/// Yenileme başladı — loading göster.
case refreshing
/// Yenileme bitti, kapanmadan önce kısa süre göster — checkmark göster.
case postLoading
/// Cooldown süresi bitmedi; `secondsRemaining` geri sayım değeri.
case cooldown(secondsRemaining: Int)
/// View kapanıyor (isteğe bağlı kullanım).
case ending
}
// MARK: - UYRefreshContentView
public protocol UYRefreshContentView: UIView {
func setState(_ state: UYRefreshState)
}
//
// UYRefreshControl.swift
// app-refresh-test2
//
// UIRefreshControl subclass. Herhangi bir UYRefreshContentView kabul eder.
//
// Kullanım:
// let control = UYRefreshControl(contentView: UYDefaultRefreshContentView(...))
// control.cooldownDuration = 60 // saniye; 0 = cooldown yok
// control.postLoadingDuration = 1.0 // saniye
// control.onRefresh = { [weak self] in
// // veri çek, ardından:
// self?.control.endRefreshing()
// }
// collectionView.refreshControl = control
//
// Pulling progress için:
// func scrollViewDidScroll(_ scrollView: UIScrollView) {
// control.updatePulling(in: scrollView)
// }
//
import UIKit
public final class UYRefreshControl: UIRefreshControl {
// MARK: - Public
/// Yenileme tetiklendiğinde çağrılır.
public var onRefresh: (() -> Void)?
/// Post-loading state'inin ekranda kalacağı süre (saniye). Varsayılan: 1.0
public var postLoadingDuration: TimeInterval = 1.0
/// Refresh tamamlandıktan sonra yeni bir refresh'in engellendiği süre (saniye).
/// 0 → cooldown yok. Varsayılan: 60
public var cooldownDuration: Int = 60
// MARK: - Private
private let contentView: any UYRefreshContentView
private let contentHeight: CGFloat
private var isInCooldown = false
private var isPendingRefresh = false
private var cooldownTimer: Timer?
private var cooldownSecondsLeft: Int = 0
// MARK: - Init
public init(contentView: any UYRefreshContentView, height: CGFloat = 98) {
self.contentView = contentView
self.contentHeight = height
super.init()
setup()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// MARK: - UIRefreshControl overrides
public override func beginRefreshing() {
super.beginRefreshing()
isPendingRefresh = false
contentView.setState(.refreshing)
}
/// endRefreshing çağrıldığında önce `.postLoading` gösterilir,
/// `postLoadingDuration` sonra gerçek kapanma gerçekleşir.
/// Kapanma bittikten sonra `cooldownDuration > 0` ise cooldown başlar.
public override func endRefreshing() {
contentView.setState(.postLoading)
DispatchQueue.main.asyncAfter(deadline: .now() + postLoadingDuration) { [weak self] in
self?.commitEndRefreshing()
}
}
// MARK: - Layout
public override var frame: CGRect {
get { super.frame }
set {
var f = newValue
f.size.height = contentHeight
super.frame = f
}
}
public override func layoutSubviews() {
super.layoutSubviews()
subviews.forEach { $0.alpha = $0 === contentView ? 1 : 0 }
contentView.frame = bounds
}
// MARK: - Pulling progress
public func updatePulling(in scrollView: UIScrollView) {
if isPendingRefresh {
if !scrollView.isDragging {
isPendingRefresh = false
triggerRefresh()
}
return
}
guard !isRefreshing, !isInCooldown else { return }
let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
guard offset < 0 else {
contentView.setState(.pulling(progress: 0))
return
}
let progress = min(abs(offset) / contentHeight, 1.0)
contentView.setState(.pulling(progress: progress))
}
// MARK: - Cooldown (public)
/// Belirtilen süreyle cooldown'ı manuel başlatır.
/// Normalde `endRefreshing()` sonrası otomatik tetiklenir;
/// sunucudan gelen dinamik bir süreyi uygulamak için bu metodu kullan.
public func startCooldown(secondsRemaining: Int) {
invalidateCooldown()
beginCooldown(seconds: secondsRemaining)
}
// MARK: - Private
private func setup() {
tintColor = .clear
addSubview(contentView)
addTarget(self, action: #selector(handleValueChanged), for: .valueChanged)
}
@objc private func handleValueChanged() {
guard !isInCooldown else {
// Cooldown süresince yeni refresh engellendi.
// Kullanıcı çektiğinde hemen kapanmasın diye yarım saniye gecikmeli kapatıyoruz.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.superEndRefreshing()
}
return
}
let scrollView = superview as? UIScrollView
if let scrollView, scrollView.isDragging {
// Parmağı hâlâ ekranda. Bırakılana kadar yenileme tetiklenmesini ertele.
isPendingRefresh = true
} else {
// Parmağını zaten çekmiş -> Hemen tetikle.
isPendingRefresh = false
triggerRefresh()
}
}
private func triggerRefresh() {
contentView.setState(.refreshing)
onRefresh?()
}
/// `super.endRefreshing()` doğrudan closure içinde çağrılamadığından
/// ayrı bir metot üzerinden tetiklenir.
private func superEndRefreshing() {
super.endRefreshing()
}
private func commitEndRefreshing() {
isPendingRefresh = false
superEndRefreshing()
// View collapse olduktan sonra cooldown başlat
let delay: TimeInterval = 0.35
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
if self.cooldownDuration > 0 {
self.beginCooldown(seconds: self.cooldownDuration)
} else {
self.contentView.setState(.pulling(progress: 0))
}
}
}
// MARK: - Cooldown (private)
private func beginCooldown(seconds: Int) {
isInCooldown = true
cooldownSecondsLeft = seconds
contentView.setState(.cooldown(secondsRemaining: cooldownSecondsLeft))
cooldownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return }
self.cooldownSecondsLeft -= 1
if self.cooldownSecondsLeft <= 0 {
self.endCooldown()
} else {
self.contentView.setState(.cooldown(secondsRemaining: self.cooldownSecondsLeft))
}
}
}
private func endCooldown() {
invalidateCooldown()
contentView.setState(.pulling(progress: 0))
}
private func invalidateCooldown() {
cooldownTimer?.invalidate()
cooldownTimer = nil
isInCooldown = false
cooldownSecondsLeft = 0
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment