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.

Sections

İ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 updateGradientFrames fonksiyonunu 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

Shadows

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.

Shadows

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.

Sketch Ball

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:

Ball

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.

Arrows Image Arrows Gif

’ 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:

  • drag statüsü kaydedilmeli edilmeli ki tüm fonksiyonlar aktif bir drag olduğunu bilsin
  • Diğer timerlar durdurulmalı ki drag sırasında başka bir animasyon tetiklenmesin. (burada durduğumuz timer daha 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
}

Ball is dragable

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
    })
}

Tap Animation

🥳 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()
}

Hint Animation

  • Ekstra: Şimdi ’layoutSubviews()’da çağırdığımız updateGradientFrames()’dan bahsedebiliriz. Arayüz objelerini oluştururken bir çok GradientLayer oluşturduk ve gerekli yerlere ekledik ancak ancak boyutlandırmadık. Hepsini ilgili viewlere 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:

  1. Ben değiştim!
  2. Ben tamamlandım!
  3. 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: .ended içinden

  • didUpdateProgress topun ilerleme yüzdesinin her değişiminde tetikleniyor. Diyelim ki başka bir labelde topun yüzdesini yazdırmak istiyoruz. currentProgress ile 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)
    }
}
  • draggableBallDidReachEnd top sona oluştuğunda,
  • draggableBallDidReturnToStart top 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