
- English
- Türkçe
Swift ile Yuvarlanan Bir Top Nasıl Yapılır?
Etiketler: Swift · UIKit · Tutorial · Animation · Interactive Components
Geçenlerde Twitter’da bir tasarımcının paylaştığı harika bir animasyona denk geldim. İç mekanizmasınına dair küçük bir ipucunu paylaşması da benim benim için bir tetikleyici oldu. Ben de yapmalıydım! Tabii ki kendi alanımda kendi stilimde 😌
Fikir için @Designownow_’a teşekkürler!
Bu yazıda gördüğünüz tasarımın hem Swift ile nasıl yapılacağını ve hem de yaparken kendime hangi soruları soruduğumu anlatacağım.
İsterseniz işin tüm ayrıntılarına hakim olmak için devam edebilirsiniz. Ya da sadece bu topu beğendiniz ve projenizde kullanmak istiyorsunuz, yazının son kısımlarına hızlıca bir göz gezdirip ardından hızlıca GitHub reposuna gidebilirsiniz.

İlk önce kısaca bölümleri yazarken kendime hangi soruları sorduğuma bakalım:
📐 Bölüm 1: Katman Katman View Hiyerarşisi
Tüm yapıya baktığımızda gördüğümüz elemenleri kafamızda kolayca ikiye bölebiliriz: kapsül yapısı ve top. Peki gölgeler? Yazı? Dönen oklar neye göre döncek? Peki ya renk geçişlerinin ayarları?
Hangisi hangisinin üzerinde? Kim kimden referans alıyor? O referansı nasıl alıyor? sorularını tek tek yanıtlıyacağız.
⚡️ Bölüm 2: Canlılık ve Etkileşim
Burada ise topa can vereceğiz. Ama nasıl? Kullanıcı buna dokunması gerektiğini nerden anlayacak? Dokunduğunda ne olucak? Dokunmayı bıraktığında ne olacak? Sürüklediğinde peki?
Hepsinin nasıl hiç bir ışınlanma ve bozulmadan olmadan ahenk içinde çalıştığına bakacağız.
🧩 Bölüm 3: Kullanım ve Yönetim
Buraya geldiğinizde her şey hazır olacak ve her şeye erişip gerçek zamanlı değiştirebileceğiz. Tek bir sorumuz tek bir cevabımız var: Neyi değiştirilebilir yapmalıyız? Hepsini.
📐 Bölüm 1: Katman Katman View Hiyerarşisi
İlk olarak kapsülün şeklinin kendisini oluşturmaya başlamalıyız:
1 - capsuleView
Asıl istediğimiz şekili veren yapı bu. burada verilecek .masksToBounds parametresi sadece kapsülün ana hatları içi: arkaplan, yazı ve gölgelerin sınırlarını barındırıyor.
2 - fillView
Benzer bir şekilde .masksToBounds burada da mevcut ancak burdaki maskeleme topun konumu tarafından tetikleniyor ve asıl temel animasyon burada gerçekleşiyor. Bu sayede yapının genel çerçevesini ayrı bir yerden animasyon değişkenleri ayrı bir yerden kontrol edebileceğiz.
3 - fillLabel
capsuleView = UIView()
capsuleView.layer.cornerRadius = capsuleHeight / 2
capsuleView.layer.masksToBounds = true
self.addSubview(capsuleView)
fillView = UIView()
fillView.layer.masksToBounds = true
capsuleView.addSubview(fillView)
fillLabel = UILabel()
fillLabel.text = self.fillText ?? "Capsule"
fillLabel.textAlignment = .center
fillLabel.textColor = self.fontColor
if let customFont = UIFont(name: fontName, size: 20) {
fillLabel.font = customFont
} else {
fillLabel.font = UIFont.systemFont(ofSize: 20, weight: .bold)
print("Custom font '\(fontName)' not available, using system font")
}
fillView.addSubview(fillLabel)
capsuleHeight = 50
capsuleHeight gibi bazı değerler dosyanın başından tanımlayıp kullanıyorum bu sayede boyut, oran-orantı vb. daha kolay kontrol edilenebiliniyor. Kodun kalabalığında kaybolmadan hızlıca değişiklikler yapmama da olanak sağlıyor. Github linkine göz atarak bakabilirsiniz. 👀
private let capsuleHeight: CGFloat = 50
private let ballSize: CGFloat = 70
private lazy var padding: CGFloat = {
(capsuleHeight - ballSize) / 2
}()
private let indicatorSize = CGSize(width: 150, height: 150)
private let maxIndicatorRotationAngle: CGFloat = .pi
private var capsuleView: UIView!
private var ballView: UIView!
private var fillView: UIView!
dosyanın en üst kısımlarından örnek
fillView’e arka plan geçişli renklerini de eklemeliyiz. Renklerin arrayi yine yukarıda erişimi kolay şekilde tanımlı olmalı. Ayrıca gradientLayerların boyutlandırılması için alttaki ekstrada bölümündeki updateGradientFrames’a bakabilirsiniz. (bkz. Github)
fillGradientLayer = CAGradientLayer()
fillGradientLayer.colors = self.fillGradientColors
fillGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5)
fillGradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
if fillView != nil {
fillView.layer.insertSublayer(fillGradientLayer, at: 0)
}
şimdilik ‘fillGradientLayer.frame = capsuleView.bounds’ gibi bir ifadeyi içeriye ekleyebiliriz. Sonra bunları topluca yapmak
updateGradientFramesfonksiyonunu ekleyeceğiz.
4- Shadows
Kapsüle uygulanan 2 adet gölge var: biri tepeden vuran bir ışığın oluşturmasına benzeyen top-edge gölgesi. Diğeri ise her taraftan vuran bir girinti efekti veren tüm kenarlara uygulanan gölge. Topun gölgesini oluştururken gradient ayarlarına daha detaylı bakacağız
Bu gölgeler sanki topun ekrandaki bir oyukta ilerlediği izlenimini veriyor. Ancak 3 boyutlu efekt her daim istenen bir şey olmayabilir, daha sonra ince ayar verilebilmesi için ekstra bir yoğunluk ayarı da ekleyeceğiz. Ve evet biliyorum topun gölgesi ve girinti efekti %100 uyumlu değil, ama şu anlık kontrol benim elimde ve böyle gözüme hoş gözüktü 😈. Ben balık tutmayı öğrettim kendi bilgisayarınızda kontrolü ele alabilirsiniz 😌
private func setupCapsuleInnerShadow() {
let shadowGradient = CAGradientLayer()
shadowGradient.frame = capsuleView.bounds
shadowGradient.cornerRadius = capsuleHeight / 2
shadowGradient.masksToBounds = true
shadowGradient.type = .radial // dairesel stil ataması
//saydam merkezden koyu kenarlara
shadowGradient.colors = [
UIColor.clear.cgColor,
UIColor.black.withAlphaComponent(cornerInnerShadowAlpha * 0.5).cgColor,
UIColor.black.withAlphaComponent(cornerInnerShadowAlpha).cgColor
]
shadowGradient.locations = [0.4, 0.7, 1.0]
shadowGradient.startPoint = CGPoint(x: 0.5, y: 0.5) // merkez
shadowGradient.endPoint = CGPoint(x: 1.0, y: 1.0) // kenarlar
if showCornerInnerShadow {
fillView.layer.addSublayer(shadowGradient)
capsuleInnerShadowLayer = shadowGradient
}
}
capsuleInnerShadowLayer.frame = capsuleView.bounds capsuleTopInnerShadowLayer.frame = capsuleView.bounds

Artık topun kendisini oluşturmaya başlayabiliriz. Burada da kullanılan elementleri hiyerarşik olarak sıralayalım.
1- underBallShadowView
Topun gölgesinin toptan daha geniş olmasını istiyoruz bu yüzden yükseklik ve genişliği topun 3 katı olarak tanımlıyoruz. Daha sonra bunları constraint ile underBallShadowView’ a uygulayacağız şu anlık alakalı kısımların birlikte durması daha iyi.
let shadowHeight = ballSize * 3
let shadowWidth = ballSize * 3
underBallShadowView = UIView()
self.addSubview(underBallShadowView)
setupBallShadowGradient()
Sadece gölge gradientinin viewini oluşturduk, şimdi şekillendirip renklendirmeliyiz.
Gradient oluşumunu biraz irdeleyelim: renk geçişlerini oluştururken, ‘colors’ arrayinin her bir ögesi ‘locations’ arrayindeki bir üye ile eşleşiyor. Mesela aşağıdaki kod bloğunun yansımasını alttaki resimde görebilirsiniz: 0.0 - 0.2 arası için ‘colors[0]’ varken, 0.2 - 0.3 arası ‘colors[1]’ rengi var ve renk geçişleri yumuşak bir geçişe sahip
Gölgenin kabaca yerleştirmesini ise şu şekilde yapabiliriz:
underBallShadowView.centerXAnchor.constraint(equalTo: ballView.centerXAnchor),
underBallShadowView.centerYAnchor.constraint(equalTo: ballView.bottomAnchor),
underBallShadowView.widthAnchor.constraint(equalToConstant: shadowWidth),
underBallShadowView.heightAnchor.constraint(equalToConstant: shadowHeight),
ballView da ekledikten sonra*
Ancak ince ayar için gradientLayer’ın iç konumlandırması ile biraz daha oynamamız gerekli. Gradienti .radial yani dairesel olarak tanımladığımız için .startPoint değeri bizim merkez değerimiz, şu anki durumda koyu rengin yayılmaya başladı koordinat. .endpoint değeri ise yayılımın bitiş noktası, aradaki mesafe farkını dairenin veya elipsin yarı çağı olarak düşünebilirsiniz.
İstediğimiz dikeyde dar, yatayda geniş bir elips. Konumu ise topun topa göre biraz daha yukarıda olmalı çünkü 3D bir efekt vermek istiyoruz.

private func setupBallShadowGradient() {
shadowGradientLayer = CAGradientLayer()
shadowGradientLayer.type = .radial
let shadowBaseColor = UIColor.black
shadowGradientLayer.colors = [
shadowBaseColor.withAlphaComponent(0.6).cgColor, // merkez renk
shadowBaseColor.withAlphaComponent(0.4).cgColor,
shadowBaseColor.withAlphaComponent(0.1).cgColor,
shadowBaseColor.withAlphaComponent(0.00).cgColor, // kenar renk
UIColor.clear.cgColor
]
shadowGradientLayer.locations = [0.2, 0.3, 0.5, 0.7] // merkez -> kenar
shadowGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.47) // merkez koordinat
shadowGradientLayer.endPoint = CGPoint(x: 1.0, y: 0.63) // kenar koordinat
if underBallShadowView != nil {
underBallShadowView.layer.addSublayer(shadowGradientLayer)
}
}
shadowGradientLayer.frame = underBallShadowView.bounds
Gradientin y ekseninde başlangıcını 0.5’den (view’in orta noktası) 0.47’e çekersek yukarı doğru %3 lük bir kayma sağlarız, bu küçük bir değer gibi görünmesine rağmen algınızla ciddi miktarda oyanar. Bu kaydırmanın sebebi bir daire ⭕️ değil küre 🪩 istememiz. Bir küreye tepeden bakarsanız kürenin yerle temas ettiği kısmı göremezsiniz, biz de gölgenin başladığı yeri görmek istemiyoruz. (Üstteki resimin sol alt köşesi)
Dağılımın merkezini taşıdık, şimdi nereye kadar dağılıcağını ayarlamalıyız. Yatayda çok daha geniş bir yapı istediğimiz için 1.0 e kadar devam edebilir (x ekseni yarı çapı 0.5). Ama dikeyde daha dar bir yapı istediğimiz için 0.63 değerini verebiliriz. Bu da bize 0.18lik bir yarı çap verir.

2 - ballView
Sırada topu oluşturmak var. Benzer bir şekilde view oluşturup .masksToBounds’ı aktifleştiriyoruz ki üstüne gelcek renkler ve oklar için bir maske görevi üstlensin.
ballView = UIView()
ballView.layer.cornerRadius = ballSize / 2
ballView.layer.masksToBounds = true
self.addSubview(ballView)
setupBallGradient()
private func setupBallGradient() {
ballGradientLayer = CAGradientLayer()
ballGradientLayer.type = .radial
ballGradientLayer.colors = [
UIColor(red: 250/255.0, green: 250/255.0, blue: 250/255.0, alpha: 1.0).cgColor, // merkez renk
UIColor(red: 149/255.0, green: 149/255.0, blue: 169/255.0, alpha: 1.0).cgColor,
UIColor(red: 129/255.0, green: 129/255.0, blue: 149/255.0, alpha: 1.0).cgColor,
UIColor(red: 149/255.0, green: 149/255.0, blue: 159/255.0, alpha: 1.0).cgColor,
UIColor(red: 210/255.0, green: 210/255.0, blue: 240/255.0, alpha: 0.9).cgColor // kenar renk
]
ballGradientLayer.locations = [0.0, 0.5 ,0.7, 0.9, 1.2] // merkez -> kenar
ballGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.35)
ballGradientLayer.endPoint = CGPoint(x: 1.1, y: 0.95)
if ballView != nil {
ballView.layer.addSublayer(ballGradientLayer)
}
}
ballGradientLayer.frame = ballView.bounds
Gördüğünüz gibi renklerde grinin hem sıcak hem soğuk hem koyu hem açık tonları var, konumlandırmada ise farklı sayılar. Bunlar bir yerden gelen ve hesaplanan sayılar değil, sadece bana güzel gözüktüler -> deneme, yanılma, kan, ter ve göz yaşı ile karar verdim 😌.
Her bir değeri kendi estetik algınıza göre değiştirmekte özgürsünüz ancak eğer benim değerlerimi girerseniz topunuz görünüşü şuna benzeyecek:

3 - indicatorImageView
Son katmanımız: dönen oklar. ‘Ok’ değil oklar çünkü kullanıcı ne kadar topun üzerinde tek bir ok görse de arka planda işler biraz farklı. 4 tane okun olduğu bir .png resmimiz var, bunu underBallShadowView eklediğimiz gibi topun altına yerleştiriyoruz. Ancak bu sefer bu yapının kendisine değil topa ekliyoruz ki .maskToBounds yetki alanına girsin.
indicatorImageView = UIImageView()
indicatorImageView.image = UIImage(named: "arrows")
indicatorImageView.contentMode = .scaleAspectFit
ballView.addSubview(indicatorImageView) // topa ekleniyor, view'in kendisine değil
private func updateIndicatorRotation(progress: CGFloat) {
let rotationAngle = progress * maxIndicatorRotationAngle
indicatorImageView.transform = CGAffineTransform(rotationAngle: rotationAngle)
}
maxIndicatorRotationAngle = .pi olarak tanımlı. Bu sayede progressi pi kadar döndürüp edip resime direkt uygulayabiliyoruz. Daha sonra da bu fonksiyonu sık sık kullanacağız.

’ ballView.masksToBounds = false ’ olursa böyle bir görüntü çıkar
Tüm constraint detaylarına GitHub linkinden detaylıca inceleyebilirsiniz. Bu konuda dikkat etmeniz geren tek yer dinamik olanlar, yani parmak hareketimiz ile uyumlu çalışıp hareketi gösterecek constraintler:
ballLeadingConstraint: topun sol kenarının konumu -> başlangıçta kapsülün geneli ile eşit.
fillWidthConstraint: kapsülün içinin genişliği. -> başlangıçta 0
Bu iki değeri de dosyanın başında properties olarak global’e tanımalıyız çünkü bu değerler ile bir çok fonksiyon oynayacak.
// Gesture ile değişecekler
ballLeadingConstraint = ballView.leadingAnchor.constraint(equalTo: capsuleView.leadingAnchor, constant: padding)
ballLeadingConstraint.isActive = true
fillWidthConstraint = fillView.widthAnchor.constraint(equalToConstant: 0)
fillWidthConstraint.isActive = true
Bununla birlikte her şeyi yerleşirmiş olduk. Şimdi topu hareketlendirme vakti.
⚡️ Bölüm 2: Canlılık ve Etkileşim
İlk olarak neye ihtiyacımız olduğunu tespit etmeliyiz.
1) Topu sürüklemek için -> UIPanGestureRecognize 👆………………👆
2) Topa tıklamak için -> UITapGestureRecognizer 👆
private func setupGestureRecognizer() {
panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
ballView.addGestureRecognizer(panGestureRecognizer)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
ballView.addGestureRecognizer(tapGestureRecognizer)
ballView.isUserInteractionEnabled = true
}
1.1 ) Top sürüklendiğinde ne olmalı?
- Top parmağı takip etmeli, temel mantık net. Ama nasıl? Bunun için birkaç soruyu daha cevaplamalıyız. 🤨
@objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) {
guard let capsuleView = self.capsuleView else { return }
let capsuleWidth = capsuleView.bounds.width
let minX = padding // 0% ilerleme
let maxX = capsuleWidth - ballSize - padding // 100% ilerleme
guard maxX > minX else { return } // önlem
ilk olarak çerçeve bilgilerini değişkenlerde saklıyoruz ki hareket sınırlarımız belli olsun
1.1.1 ) Topa dokunmaya başladığımızda ne olmalı? -> case .began:
dragstatüsü kaydedilmeli edilmeli ki tüm fonksiyonlar aktif birdragolduğunu bilsin- Diğer
timerlar durdurulmalı kidragsırasında başka bir animasyon tetiklenmesin. (burada durduğumuztimerdaha sonra oluşturacağım ip ucunu mekaniğini durduruyor ki çakışmasınlar) - tüm
viewlerin güncel pozisyonlarını kaydedilmeli ki, ‘drag’ ın başlangıcını yeni elde ettiğimiz veriler ile başlatabilelim.
💡 Burdaki “.layer.presentation” yapısı önemli. Herhangi bir zıplama veya flicker yaşamamak için canlı konumu takip etmeliyiz.
case .began:
isDragging = true // Set dragging state and stop the hint timer when user starts dragging
isDragging = true // sürüklenme durumu kaydilmeli ve timer durdurulmalı
stopHintTimer() // kullanıcıya feedback veren timer
if ballView.layer.animationKeys()?.isEmpty == false { // eğer aktif bir animasyon varsa
let currentBallConstant = ballView.layer.presentation()?.frame.origin.x ?? ballLeadingConstraint.constant // anlık pozisyonu al
let currentFillWidth = fillView.layer.presentation()?.bounds.width ?? fillWidthConstraint.constant // anlık geni
let finalIndicatorTransform: CGAffineTransform
if let presentationTransform = indicatorImageView.layer.presentation()?.transform {
finalIndicatorTransform = CATransform3DGetAffineTransform(presentationTransform) // anlık açıyı al
} else {
finalIndicatorTransform = indicatorImageView.transform // eğer anlık alınmazsa önelm olarak varsayılan halini al
}
ballView.layer.removeAllAnimations() // tüm animasyonları kaldır
fillView.layer.removeAllAnimations()
indicatorImageView.layer.removeAllAnimations()
underBallShadowView.layer.removeAllAnimations()
ballLeadingConstraint.constant = currentBallConstant
fillWidthConstraint.constant = currentFillWidth
indicatorImageView.transform = finalIndicatorTransform
/// eğer aktif bir animasyon varsa, anlık pozisyonu Constraint.constant 'lara eşitle,
/// mesela aktif bir animasyonu bölünürse, sürüklemenin başladığı yerde eski animasyonu dondurmalı ve canlı pozisyonu kaydırma-başlangıç noktası olarak kaydedilmeli.
/// bu üstüste binme ve zıplama-ışınlanma problemlerinin önüne geçer
self.layoutIfNeeded()
}
dragStartLeadingConstant = ballLeadingConstraint.constant // sürükleme başlangıç değerini kaydediyoruz, artık başlayabilir
Timer ve delegate metodlarını daha sonra inceleyeceğiz
1.1.2 ) Topu sürekleme esnasında ne olmalı? -> case .changed:
- Parmağın, kapsüle oranla x eksenindeki hareketin kooridinatını alıp bunu topa uygun bir şekilde uygulamalıyız.
- Ancak kapsülden dışarıya taşmaması için sınır tanımlamalıyız. ⛔️
- Kapsülün arka planını ve topun gölgesi de bu yeni posizyona göre dinamik olarak uyum sağlamalı. Ayrıca topun üzerindeki okları yeni pozisyona göre döndürmemiz de gerekli.
case .changed:
let translationX = gesture.translation(in: capsuleView).x // x eksenindeki canlı dönüşümü al
var newLeadingConstant = dragStartLeadingConstant + translationX // başlangıç + sürüklenme miktarı = yeni lokasyon
newLeadingConstant = max(minX, min(newLeadingConstant, maxX)) // sınırları belirle
let transformTx = newLeadingConstant - dragStartLeadingConstant
let dragTransform = CGAffineTransform(translationX: transformTx, y: 0) // Yeni konum değil, başlangıç konumundan hareket edilen mesafenin farkı (delta)
ballView.transform = dragTransform // dönüşümğ uygula
underBallShadowView.transform = dragTransform
let normalizedProgress = (newLeadingConstant - minX) / (maxX - minX) // örneğin %73 -> 0.73 dönüşümü
delegate?.draggableBall(self, didUpdateProgress: normalizedProgress) // delegeleri bilgilendir
fillWidthConstraint.constant = capsuleWidth * normalizedProgress // ilerlemeye göre doluluk genişliğini ayarla
updateIndicatorRotation(progress: normalizedProgress) // göstergenin dönüşünü güncelle
1.1.3 ) Topu bıraktığımızda veya dokunuşu iptal ettiğimizde ne olmalı? -> case .ended, .cancelled:
- Drag statüsünü tekrar kaydedilmeli.
- Final pozisyonlar her objeye kalıcı ‘constant’ ler ile sabitlenmeli, geçici ’transform’ları da orijinal hallerine dönmeli.
- Bundan sonra delegate bildirimleri ve timer ayarlarını yapabiliriz.
case .ended, .cancelled:
isDragging = false // yeni durumu ayarla
let finalTranslationX = gesture.translation(in: capsuleView).x // son koordinatı al
var finalLeadingConstant = dragStartLeadingConstant + finalTranslationX
finalLeadingConstant = max(minX, min(finalLeadingConstant, maxX))
ballLeadingConstraint.constant = finalLeadingConstant // topu kalıcı olarak finale ayarla
self.ballView.transform = .identity // varsayılana geri dön
self.underBallShadowView.transform = .identity // varsayılana geri dön
self.layoutIfNeeded() // UI tamam
let finalProgress = self.getCurrentProgress() // topun konumuna göre ilerlemeyi al
self.delegate?.draggableBall(self, didUpdateProgress: finalProgress) // delegeleri bilgilendir
checkProgressThresholdsAndNotifyDelegate(progress: finalProgress) // delegeleri bilgilendir
// Sürükleme tamamlandıktan sonra ilerlemeyi kontrol et ve zamanlayıcıyaları ayarla
self.checkProgressAndManageTimer()
default:
break
}

2.1 ) Topa tıklandığında ne olmalı?
- Topun hareketli bir şey olduğunu kullanıcıya bildirmek istiyorum ve bunu topla olan her interaksiyonunda vermeliyiz. Yani mesela topa tıkladığında bir miktar hareketlilik verirsek, top kullanıcıya şu mesajı verir “Benimle oynamaya devam et ben hareketli bir objeyim, canlıyım!”
Bu canlılık efektini top ilk oluştuğunda ve daha hareket etmemişken de verilmeli çünkü daha hiç etkileşim almamışken de “Bana dokun! 👆” mesajını kullanıcıya ulaşmalı. Kod içerisinde gördüğünüz ‘hint’ ile ilgili fonksiyonlar bunun için var. Yazının ilerleyen kısımlarında onları da inceleceğiz ancak şu anki kodlardaki başlatıcı ve durdurucuların konumları da animasyonların çakışmaması için önemli o yüzden şimdilik gelecekten bir parça olarak kalabilirler 😌
@objc private func handleTapGesture(_ gesture: UITapGestureRecognizer) {
stopHintTimer() // ipucu zamanlayıcısını durdur
performTapHintAnimation() // dokunma animasyonunu başlat
}
İki tane hint mekanizmamız var biri uygun şartlar altında sürekli tekrarlanan (performSimpleHintAnimation), biri sadece dokunduğumuzda tetiklenen. İlk olarak dokunduğumuzda tetiklenen ‘performTapHintAnimation’ üzerine odaklanalım:
Ne istiyoruz? Kıpırdamasını 🫨. Bunu manuel olarak verdiğimiz değerler ile belli bir hareketi tekrarlatabiliriz.
Ama hangi tarafa? Bunu sormadan önce şunu sormalıyız: Top nerde? Normal şartlarda topun ileri doğru hareket etmesini isteriz çünkü kullanıcıdan ileri kaydırmasını istiyoruz. Peki ya top zaten sondaysa? İşte o zaman top geriye doğru kıpırdamalı, hem bu sayede sanki kapsülün sınırı bir duvarmış da top geri sekiyormuş efekti sağalayabiliriz. Animasyonu yazarken bu durumu da değerlendirmeliyiz.
private func performTapHintAnimation() {
guard let capsuleView = self.capsuleView,
capsuleView.bounds.width > 0 else { return } // önlem
if ballView.layer.animationKeys()?.isEmpty == false {
let currentBallConstant = ballView.layer.presentation()?.frame.origin.x ?? ballLeadingConstraint.constant
let currentFillWidth = fillView.layer.presentation()?.bounds.width ?? fillWidthConstraint.constant
let finalIndicatorTransform: CGAffineTransform
if let presentationTransform = indicatorImageView.layer.presentation()?.transform {
finalIndicatorTransform = CATransform3DGetAffineTransform(presentationTransform)
} else {
finalIndicatorTransform = indicatorImageView.transform
}
ballView.layer.removeAllAnimations()
fillView.layer.removeAllAnimations()
indicatorImageView.layer.removeAllAnimations()
underBallShadowView.layer.removeAllAnimations()
ballLeadingConstraint.constant = currentBallConstant
fillWidthConstraint.constant = currentFillWidth
indicatorImageView.transform = finalIndicatorTransform
self.layoutIfNeeded() // Yeni animasyona başlamadan önce son değerleri uygula
}
let capsuleWidth = capsuleView.bounds.width // handlePanGesture’dakiyle aynı mekanizma
let minX = padding
let maxX = capsuleWidth - ballSize - padding
guard maxX > minX else { return }
let totalDistance = maxX - minX
let startConstant = ballLeadingConstraint.constant
let hintDistance = totalDistance * hintAnimationAmount * 2 // hintAnimationAmount = 0.05
var targetConstant = startConstant + hintDistance // hedef konumu belirle
if targetConstant > maxX { // hedef konum sağ sınırı aşarsa, hareket yönü tersine çevrilir (sola kaydırılır)
targetConstant = startConstant - hintDistance
}
targetConstant = max(minX, min(targetConstant, maxX)) // her ihtimale karşı hedef konumun geçerli aralıkta olduğundan emin ol
let startNormalizedProgress = (startConstant - minX) / totalDistance // örnek: 0.42
let targetNormalizedProgress = (targetConstant - minX) / totalDistance // örnek: 0.46
let startFillWidth = fillWidthConstraint.constant
let targetFillWidth = capsuleWidth * targetNormalizedProgress
// Bir adım ileri, bir adım geri
UIView.animateKeyframes(withDuration: 1.0, delay: 0, options: [.calculationModeLinear, .allowUserInteraction], animations: {
// İleri hareketi (sürenin 0-40%'ı)
UIView.addKeyframe(withRelativeStartTime: 0.0, relativeDuration: 0.4) {
self.ballLeadingConstraint.constant = targetConstant // topu ileri haraket ettir
self.fillWidthConstraint.constant = targetFillWidth // doluluğu il haraket ettir
self.updateIndicatorRotation(progress: targetNormalizedProgress) // okların açısını güncelle
self.layoutIfNeeded()
}
// Başlangıca geri dön (sürenin 40-100%'ı)
UIView.addKeyframe(withRelativeStartTime: 0.4, relativeDuration: 0.6) {
self.ballLeadingConstraint.constant = startConstant // topu başlangıca geri döndür
self.fillWidthConstraint.constant = startFillWidth // doluluğu başlangıca geri döndür
self.updateIndicatorRotation(progress: startNormalizedProgress) // tekrar okalrı güncelle
self.layoutIfNeeded()
}
}, completion: { [weak self] _ in
self?.checkProgressAndManageTimer() // Tamamlandıktan sonra ilerlemeyi kontrol et ve zamanlayıcıyı ayarla
})
}

🥳 Artık topumuz daha da canlı!
🔎 Az önce atladığımız ‘hint’ bölümünü şimdi ele alabiliriz. Burada topun canlılığına dair feedback vereceğiz. Yine aynı soruya geri dönmeliyiz: Nasıl?
- Tıklama animasyonu gibi bir kıpırdanma istiyoruz ve bunun periyodik olarak tekrarlanmalıyız. Bu da bize daha çok soru çıkartıyor.
Ne zaman? ⏰
- Ekran açıldığında otomatik olarak başlamasını istiyoruz ki kullanıcın dikkatini çekmeye direkt başlasın. Ayrıca bu işlemin sürekli olarak tekrarlamasını da istiyoruz.
private var hintTimer: Timer?
private let hintTimerInterval: TimeInterval = 2.0 // aralarında 2 saniye var
ilk olarak 2 saniye aralıklı bir timer a ihtiyacımız var
Layout yerleşimi her tamaladığında veya değiştiğinde çağırılan ‘layoutSubviews()’ ile ilk animasyonu tetiklemeliyiz çünkü her top yapısının kendine özgü hareket etmesini istiyoruz. İlk animasyonu ekran oluşur oluşmaz tetikliyoruz. Ardından (bir animasyon süresi kadar sonra) ’hintTimer’ı yönetecek olan checkProgressAndManageTimer() çağırıyoruz.
override func layoutSubviews() {
super.layoutSubviews()
updateGradientFrames() // gradientlerin boyut ve konum güncellemeleri
// Kaosunu önlemek için ipucu animasyonunu yalnızca bir kez ve gecikmeli başlat
if !hintAnimationCompleted && bounds.width > 0 {
hintAnimationCompleted = true // Tekrarlı çağrıları hemen engelle, layoutSubviews sık sık tetiklenir
DispatchQueue.main.asyncAfter(deadline: .now()) { [weak self] in
self?.performSimpleHintAnimation()
// İlk animasyondan sonra zamanlayıcıyı başlat
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
self?.checkProgressAndManageTimer()
}
}
}
}
checkProgressAndManageTimer(): İpucu animasyonun sadece top en baştayken olmasını istiyoruz. Çünkü neden zaten kaydırılmış bir şeye “Beni kaydırabilirsin” mesajı verdirtelim ki? Kullanıcı zaten istediğimizi yapmış. Bu yüzden topun kaydırılma yüzdesini alıp buna göre hareket ediyoruz, minik bir tolerans değeri de ekleyerek sadece başlangıçtaysa tetikliyoruz.
// ilerlemeyi kontrol eder ve zamanlayıcıyı buna göre ayarla
private func checkProgressAndManageTimer() {
let currentProgress = getCurrentProgress()
// tam eşleşme yerine tolerans kullan - sadece başlangıca çok yakınsa zamanlayıcıyı başlat
if abs(currentProgress) < progressTolerance {
startHintTimer() // ilerleme temelde 0.0, zamanlayıcıyı aktif tut
} else {
stopHintTimer() // ilerleme toleranstan büyük, zamanlayıcıyı durdur
}
}
// ipucu zamanlayıcısını başlatır
private func startHintTimer() {
stopHintTimer() // önce varsa mevcut zamanlayıcıyı durdur
hintTimer = Timer.scheduledTimer(withTimeInterval: hintTimerInterval, repeats: true) { [weak self] _ in
self?.performSimpleHintAnimation()
}
}
// ipucu zamanlayıcısını durdurur
private func stopHintTimer() {
hintTimer?.invalidate()
hintTimer = nil
}
// sayfa bellekten atıldığında zamanlayıcıyı temizle
deinit {
stopHintTimer()
}

- Ekstra: Şimdi ’
layoutSubviews()’da çağırdığımızupdateGradientFrames()’dan bahsedebiliriz. Arayüz objelerini oluştururken bir çokGradientLayeroluşturduk ve gerekli yerlere ekledik ancak ancak boyutlandırmadık. Hepsini ilgiliviewlere ekledikten sonra çevrelerini eşitlemek için aşağıda fonksiyonu çağırmalıyız. Şu ana kadar bunları layerı oluştururken tanımlamış olabilirsiniz ancak güvenlik önlemleri ile böyle de tanımlayabilirsiniz.
Bu fonksiyonu tüm arayüz hesaplandıktan sonra çağırıyoruz ki çizeceğimiz sınırlar belli olsun.
private func updateGradientFrames() {
// Eğer nil değillerse ve görünümlerin boyutları varsa, gradient katmanlarının çerçevelerini eşle
if underBallShadowView != nil && underBallShadowView.bounds != .zero && shadowGradientLayer != nil {
shadowGradientLayer.frame = underBallShadowView.bounds
}
if ballView != nil && ballView.bounds != .zero && ballGradientLayer != nil {
ballGradientLayer.frame = ballView.bounds
}
if fillView != nil && capsuleView != nil && capsuleView.bounds != .zero && fillGradientLayer != nil {
fillGradientLayer.frame = capsuleView.bounds
}
if capsuleView != nil && capsuleView.bounds != .zero && capsuleInnerShadowLayer != nil {
capsuleInnerShadowLayer.frame = capsuleView.bounds
}
if capsuleView != nil && capsuleView.bounds != .zero && capsuleTopInnerShadowLayer != nil {
capsuleTopInnerShadowLayer.frame = capsuleView.bounds
}
}
🧩 Bölüm 3: Kullanım ve Yönetim
🎉 Kurulumu Tamamladık! Şimdi topumuzu fonksiyonel olarak projelerde kullanmak istiyoruz. Mesela aktif ilerleme yüzdesini yazdırmak, kaydırma tamamlandığında ya da tekrar başa döndüğünde haberdar olmak isteyebiliriz. Belki de kendimiz manuel yüzde atamak istiyoruz? Hatta aynı anda içindeki yazıyı, renkleri veya fontu değiştirebilmeliyiz.
draggableBall = DraggableBall(frame: .zero, fillText: "Capsule", showCornerInnerShadow: true, showTopInnerShadow: true, cornerInnerShadowAlpha: 0.2, topInnerShadowAlpha: 0.2)
İstediğiniz ayarlarda direkt bu şekilde kullanmaya başlayabilirsiniz
draggableBall.updateFont(name: "Cheetah Kick - Personal Use", size: 36, color: .white)
draggableBall.fillGradientColors = [ // didSet değişkilikleri direk uygular
UIColor.systemBlue.cgColor,
UIColor.systemCyan.cgColor,
UIColor.systemTeal.cgColor
]
Ve hemen ardından istediklerinizi değiştirebilirsiniz
@objc func randomButtonTapped() {
let randomProgress = CGFloat.random(in: 0...1)
draggableBall.setProgress(randomProgress, animated: true)
}
Örnek kullanımı
Bu değişiklikler ve topun durumuna direkt erişmek için accessor (get) ve mutator (set) metodlarımız var. Aşağıda birkaç kod örneği ve hazırda olan tüm metodların listesini görebilirsiniz.
- Tamamlanma durumunu erişmek için:
isCompleted
public var isCompleted: Bool {
return currentProgress >= 0.99
}
- Label’ın fontunu, boyutunu, rengini değiştirmek için:
updateFont
// yazı tipini günceller
public func updateFont(name: String, size: CGFloat = 20, color: UIColor? = nil) {
fontName = name
if let customFont = UIFont(name: name, size: size) {
fillLabel.font = customFont
} else {
fillLabel.font = UIFont.systemFont(ofSize: size, weight: .bold)
print("Custom font '\(name)' not available, using system font")
}
// Eğer verilmişse, rengi güncelle
if let color = color {
fontColor = color
fillLabel.textColor = color
}
}
// Sadece metnin rengini günceller
public func updateFontColor(_ color: UIColor) {
fillLabel.textColor = color
}
- Gölgelerin varlığını ve yoğunluğunu değiştirmek için:
updateCornerInnerShadow
public func updateCornerInnerShadow(show: Bool, alpha: CGFloat) {
showCornerInnerShadow = show
cornerInnerShadowAlpha = alpha
if show && capsuleInnerShadowLayer == nil { // Gerekirse gölgeyi yeniden oluştur
setupCapsuleInnerShadow()
} else if !show && capsuleInnerShadowLayer != nil {
capsuleInnerShadowLayer?.removeFromSuperlayer()
capsuleInnerShadowLayer = nil
}
}
public func updateTopInnerShadow(show: Bool, alpha: CGFloat) {
showTopInnerShadow = show
topInnerShadowAlpha = alpha
if show && capsuleTopInnerShadowLayer == nil { // Gerekirse gölgeyi yeniden oluştur
setupCapsuleTopInnerShadow()
} else if !show && capsuleTopInnerShadowLayer != nil {
capsuleTopInnerShadowLayer?.removeFromSuperlayer()
capsuleTopInnerShadowLayer = nil
}
}
Mutators (Setters)
- setProgress(animated)
- updateText()
- updateFont(name, size, color)
- updateFontColor()
- updateFontSize()
- updateFillGradientColors()
- updateCornerInnerShadow(show, alpha )
- updateTopInnerShadow(show, alpha)
- updateBallSize()
- updateIndicatorSize()
- updateIndicatorTopOffset()
- updateCapsuleHeight()
- updateCapsuleWidth()
Accessors (Getters)
- currentProgress
- isCompleted:
Tasarımlarınızı hızlıca deneyip, direkt kod kullanımlarını görebilmeniz için proje içerine bir test ekranı da ekledim 🫡. Yukarıdaki de sadece font kısmına ait bir kesit.
💡 Delegate tanımlarken biraz teknik detaylara girmeliyiz ancak GitHub’taki örnekleri de direkt kullanabilirsiniz
İstediğimiz değişiklikleri manuel olarak yukardaki metodlar ile verebiliriz ama delegate ile de topun durumundan haberdar olmalıyız. Mesela top bize şunları söylemeli ki ondan haberimiz olsun:
- Ben değiştim!
- Ben tamamlandım!
- Ben başa döndüm!
protocol DraggableBallDelegate: AnyObject {
/// Delegelere ilerlemenin güncellendiğini bildirir.
/// - Parametreler:
/// - draggableBall: Tetikleyen DraggableBall.
/// - progress: Yeni ilerleme değeri, 0.0 ile 1.0 arasında.
func draggableBall(_ draggableBall: DraggableBall, didUpdateProgress progress: CGFloat)
/// Delegelere sürüklemenin sona ulaştığını bildirir (ilerleme >= 1.0).
/// - Parametre draggableBall: Tetikleyen DraggableBall.
func draggableBallDidReachEnd(_ draggableBall: DraggableBall)
/// Delegelere sürüklemenin başlangıç noktasına ulaştığını bildirir (progress <= 0.0).
/// - Parametre draggableBall: Tetikleyen DraggableBall.
func draggableBallDidReturnToStart(_ draggableBall: DraggableBall)
}
Projenin başından beri yazdığımız kodların arasında aşağıdaki satırlar benzer ifadeler vardı:
self.delegate?.draggableBall(self, didUpdateProgress: finalProgress) // delegelere bildir
checkProgressThresholdsAndNotifyDelegate(progress: finalProgress) // delegelere bildir
handlePanGesture() case: .endediçinden
-
didUpdateProgresstopun ilerleme yüzdesinin her değişiminde tetikleniyor. Diyelim ki başka bir labelde topun yüzdesini yazdırmak istiyoruz.currentProgressile buna erişebiliriz ama ne sıklıkla ekrana yazdıracağımızı bilmiyoruz. İşte bu yüzden delegatelere ihtiyaç var.Top kendisi her değiştiğinde ilerlemesini ekrana yazdıran labela gidip “Hey ben değiştim! Anlık ilerlememi tekrar al ve kendini güncelle!” demeli.
private func checkProgressThresholdsAndNotifyDelegate(progress: CGFloat) {
if progress >= 0.97 {
delegate?.draggableBallDidReachEnd(self)
} else if progress <= 0.03 {
delegate?.draggableBallDidReturnToStart(self)
}
}
draggableBallDidReachEndtop sona oluştuğunda,draggableBallDidReturnToStarttop başa döndüğünde tetikleniyor.
extension SingleBallAnimationVC: DraggableBallDelegate {
func draggableBall(_ draggableBall: DraggableBall, didUpdateProgress progress: CGFloat) {
progressLabel.text = String(format: "Progress: %.2f", progress)
}
func draggableBallDidReachEnd(_ draggableBall: DraggableBall) {
print("Ball reached the end!")
emojiLabel.text = "🎬"
}
func draggableBallDidReturnToStart(_ draggableBall: DraggableBall) {
print("Ball returned to the start.")
emojiLabel.text = "🎉"
}
}
kullanım örneği
🏁 Kapanış
Hem kodlarken hem bu yazıyı yazarken hem de gördüğünüz resimleri çizerken oldukça eğlendim. 😌
👩🏻💻 Kodlarken her daim kendime sorular sorup cevaplamaya çalışırım, yazı içersinde de kendime sorduğum soruları olabildiğince size yansıtmaya çalıştım. Çünkü klavye başında kod yazarken kendi zihin akışım da aynı böyle ilerliyor.
👩🏻🎨 Çizdiğim resimler ise kendi öğrenim sürecimde hep aradığım şeylerdi, ben de bu sefer öğretici olarak bunları size direkt sunmak istedim. Görsel bir şeyi sayılar ve harfler ile inşa ediyoruz ve bunların neyi, nasıl oluşturduğunu net ve hızlıca görmek bence önemli. Umarım size de yardımcı olmuştur.
✉️ Eğer yazıyla ilgili aklınıza takılan bir şey olursa ya da sadece düşüncelerinizi paylaşmak isterseniz bana email atabilirsiniz.
👋🏻 İleride görüşmek üzere!
github: zeynepmuslim/draggable-ball