Created
July 28, 2026 06:09
-
-
Save uy/a88acdcb6f519ef34c1e2a6097e5b409 to your computer and use it in GitHub Desktop.
scroll header
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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 | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // 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