iOS App Development
Swift ile UIBezierPath Kullanarak Stepper Bileşeni Tasarımı
Mayıs 17, 2023
Günümüz web ve mobil uygulamalarında kullanıcı deneyimi ve ergonomi artık en önemli metriklerin başında geliyor. Bu doğrultuda, özellikle ödeme, kayıt olma veya kimlik doğrulama gibi aşamalı işlemlerde “stepper” (adım takip bileşeni) önde gelen ekran tasarımı trendlerinden biri olarak karşımıza çıkıyor. Kullanıcının hangi adımda olduğunu ve önünde nasıl bir yol haritası bulunduğunu gösteren stepper, göze hoş gelen bir tasarımla sunulduğunda kullanıcı deneyimini üst seviyeye taşıyor.



Bu yazıda, iOS ekosisteminde Swift dili ve UIBezierPath kullanarak çok fonksiyonlu bir stepper bileşenini nasıl tasarlayabileceğimizi ve uygulama ekranlarına nasıl kolayca entegre edebileceğimizi anlatacağım. Keyifli okumalar!
Stepper Bileşeninin Özellikleri
Bileşenin çalışma mantığına geçmeden önce, kullanacağımız değişkenlere ve nesnelere göz atalım. Farklı senaryolara uyum sağlayabilmesi adına öncelikle stepper tipini seçeceğimiz bir enum yapısı oluşturuyoruz. Bu yapı .Numeric ve .Icon olmak üzere iki durumdan (case) oluşuyor. Böylece ihtiyacımıza göre sayılardan veya ikonlardan oluşan bir stepper tasarlayabiliyoruz. Eğer .Icon seçeneğini tercih edersek, süreç stepperIcons adındaki bir sözlük (dictionary) üzerinden besleniyor. Bu sözlük, adımlarda kullanılacak ikonların Assets klasöründeki ya da sistemdeki (SF Symbols) isimlerini barındırıyor.
import UIKit
@IBDesignable
final class StepperView: UIView {
enum StepperType {
case Numeric
case Icon
}
/// The stepper type selection
var stepperType: StepperType = .Numeric
var stepperIcons = [Int:String]()
numberOfPoints, currentIndex ve completedTillIndex değişkenleri; stepper bileşeninin kaç adımdan oluşacağı, aktif adımın hangisi olduğu ve o ana kadar tamamlanan adımların bilgisi gibi kritik verileri tutar. Bu değerleri adımları ekrana çizerken kullanacağız.
lineHeight ve radius değişkenleri ise çizim yapmamızı sağlayan en önemli yapılardır. lineHeight iki adım arasındaki çizginin kalınlığını, radius ise dairesel adımların yarıçapını belirler. Her iki değişkenin de _lineHeight ve _radius adında hesaplanmış özellikleri (computed property) bulunur; asıl sınır kontrolleri ve hesaplamalar bu bloklarda gerçekleştirilir.
/// The number of displayed points in the component
@IBInspectable var numberOfPoints: Int = 3 {
didSet {
setNeedsDisplay()
}
}
/// The current selected index
@IBInspectable var currentIndex: Int = 1 {
didSet {
setNeedsDisplay()
}
}
@objc var completedTillIndex: Int = -1 {
didSet {
setNeedsDisplay()
}
}
@objc private var currentSelectedCenterColor = UIColor(red: 101.0/255.0, green: 66.0/255.0, blue: 190.0/255.0, alpha: 1.0)
@objc private var centerLayerTextColor = UIColor(red: 156.0/255.0, green: 145.0/255.0, blue: 158.0/255.0, alpha: 1.0)
private var lineHeight: CGFloat = 1.0 {
didSet {
setNeedsDisplay()
}
}
@objc private var textDistance: CGFloat = 20.0 {
didSet {
setNeedsDisplay()
}
}
private var _lineHeight: CGFloat {
get {
if lineHeight == .zero || lineHeight > bounds.height {
return bounds.height * 0.4
}
return lineHeight
}
}
/// The point's radius
private var radius: CGFloat = 40.0 {
didSet {
setNeedsDisplay()
}
}
private var _radius: CGFloat {
get {
if radius == .zero || radius > bounds.height / 2.0 {
return bounds.height / 2.0
}
return radius
}
}
CALayer, CAShapeLayer ve CATextLayer Yapıları
Stepper bileşenini çizerken, Core Animation kullanan CALayer ve CATextLayer yapılarını kullandım. Aslında, kullanıcının ekranda gördüğü tasarımlar layer’lardan oluşur. Bu layer’lar üzerine yerleştirdiğimiz farklı nesnelerle tasarımın tamamını ortaya çıkarırız.
Tasarlayacağımız bileşen için gerekli olan layer’ları private bir değişkende tutuyoruz. Ardından, bileşenimiz bir commonInit fonksiyonu ile init edildiğinde, bu layer’ları sublayer olarak ana layer’a ekliyoruz. Örnek olarak burada birkaç farklı layer oluşturdum. Bu layer’ların hepsini bileşende kullanmıyorum.
// MARK: - Private properties
private var backgroundLayer = CALayer()
private var progressLayer = CAShapeLayer()
private var selectionLayer = CAShapeLayer()
private var clearSelectionLayer = CAShapeLayer()
private var clearLastStateLayer = CAShapeLayer()
private var lastStateLayer = CAShapeLayer()
private var lastStateCenterLayer = CAShapeLayer()
private var selectionCenterLayer = CAShapeLayer()
private var roadToSelectionLayer = CAShapeLayer()
private var clearCentersLayer = CAShapeLayer()
private var maskLayer = CAShapeLayer()
private var centerPoints = [CGPoint]()
private var _textLayers = [Int: CATextLayer]()
private var _customImageLayers = [Int: CALayer]()
private var _imageLayers = [Int: CALayer]()
private var previousIndex: Int = 0
// MARK: - Life cycle
override init(frame: CGRect = .zero) {
super.init(frame: frame)
commonInit()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
func commonInit() {
layer.addSublayer(clearCentersLayer)
layer.addSublayer(backgroundLayer)
layer.addSublayer(progressLayer)
layer.addSublayer(clearSelectionLayer)
layer.addSublayer(selectionCenterLayer)
layer.addSublayer(selectionLayer)
layer.addSublayer(roadToSelectionLayer)
progressLayer.mask = maskLayer
contentMode = UIView.ContentMode.redraw
}
draw() fonksiyonu
override func draw(_ rect: CGRect) {
super.draw(rect)
completedTillIndex = currentIndex
centerPoints.removeAll()
let distanceBetweenCircles = (bounds.width - (CGFloat(numberOfPoints) * 2 * _radius)) / CGFloat(numberOfPoints - 1)
var xCursor: CGFloat = _radius
for _ in 0...(numberOfPoints - 1) {
centerPoints.append(CGPoint(x: xCursor, y: bounds.height / 2))
xCursor += 2 * _radius + distanceBetweenCircles
}
let bgPath = _shapePath(centerPoints, aRadius: _radius, aLineHeight: _lineHeight)
backgroundLayer = bgPath
switch stepperType {
case .Numeric:
renderTextIndexes()
case .Icon:
renderCustomImageIndexes()
}
renderImageIndexes()
}
Bu fonksiyonu, ekranda çizim yapmak için tüm parçaları bir araya getirdiğimiz ana kurulum merkezi olarak düşünebiliriz. UIView sınıfından geçersiz kıldığımız (override) draw metodu ile StepperView bileşeninin içini dolduruyoruz.
- completedTillIndex ve currentIndex eşleşmesi üzerinden stepper bileşeninin anlık durumunu kontrol ediyoruz.
- centerPoints dizisi, adımların ekrandaki tam koordinat noktalarını tutan bir CGPoint dizisidir.
- Adımlar arasındaki mesafeyi eşit paylaştırabilmek için toplam stepper genişliğinden adımların kapladığı toplam çap alanını çıkarıyor ve çıkan sonucu adım sayısının bir eksiğine bölüyoruz.
- for döngüsü içinde, her bir adımın çizileceği merkez noktasını hesaplayıp centerPoints dizisine ekliyoruz. Buradaki xCursor değişkeni, yatay eksende bir sonraki adıma geçmemizi sağlayan bir imleç görevi görür.
- Hesaplamaların ardından _shapePath fonksiyonundan dönen katmanı backgroundLayer değişkenine atıyoruz.
- Son olarak seçilen stepperType değerine göre renderTextIndexes, renderImageIndexes veya renderCustomImageIndexes fonksiyonlarını çağırarak adımların içeriklerini belirliyoruz..
renderTextIndexes(), renderImageIndexes(), renderCustomImageIndexes() fonksiyonları
Layer çizimlerinin haricinde, bir diğer ana konu da layer içinde adım index’lerinin oluşturulmasıdır. Bu örnekte, bileşeni üç layer üzerinde oluşturdum. Eğer stepper .numeric olarak kullanılacaksa, stepper çizimi için renderTextIndexes() ve renderImageIndexes() fonksiyonları yeterlidir. Eğer stepper .icon formatındaysa, renderImageIndexes() ve renderCustomIndexes() fonksiyonlarını kullanırız.
private func renderTextIndexes() {
if (stepperType == .Numeric) {
for index in 0...(numberOfPoints - 1) {
let centerPoint = centerPoints[index]
let textLayer = _textLayer(atIndex: index)
textLayer.contentsScale = UIScreen.main.scale
textLayer.font = centerLayerTextFont
textLayer.fontSize = (centerLayerTextFont?.pointSize)!
if index == currentIndex || index == completedTillIndex {
textLayer.foregroundColor = UIColor.white.cgColor
} else {
textLayer.foregroundColor = centerLayerTextColor.cgColor
}
if index < currentIndex {
textLayer.string = ""
} else {
textLayer.string = "(index + 1)"
}
textLayer.frame = .init(origin: CGPoint(x: 0.0, y: 0.0), size: textLayer.preferredFrameSize())
textLayer.frame = CGRect(x: centerPoint.x - textLayer.bounds.width / 2,
y: centerPoint.y - (textLayer.fontSize) / 2 - (textLayer.bounds.height - textLayer.fontSize) / 2,
width: textLayer.bounds.width,
height: textLayer.bounds.height)
}
}
}
private func renderImageIndexes() {
for index in 0...(numberOfPoints - 1) {
let centerPoint = centerPoints[index]
let imageLayer = _imageLayer(atIndex: index)
imageLayer.contentsScale = UIScreen.main.scale
if (index < currentIndex) {
imageLayer.isHidden = false
} else {
imageLayer.isHidden = true
}
imageLayer.frame.size = CGSize(width: 21, height: 21)
imageLayer.frame = CGRect(x: centerPoint.x - imageLayer.bounds.width / 2,
y: centerPoint.y - imageLayer.bounds.height / 2,
width: imageLayer.bounds.width,
height: imageLayer.bounds.height)
}
}
private func renderCustomImageIndexes() {
for index in 0...(numberOfPoints - 1) {
let centerPoint = centerPoints[index]
let customImageLayer = _customImageLayer(atIndex: index)
customImageLayer.contentsScale = UIScreen.main.scale
if !(index < currentIndex) {
customImageLayer.isHidden = false
} else {
customImageLayer.isHidden = true
}
if (index == numberOfPoints - 1) {
customImageLayer.frame.size = CGSize(width: 18, height: 20.24)
} else {
customImageLayer.frame.size = CGSize(width: 21, height: 21)
}
customImageLayer.frame = CGRect(x: centerPoint.x - customImageLayer.bounds.width / 2,
y: centerPoint.y - customImageLayer.bounds.height / 2,
width: customImageLayer.bounds.width,
height: customImageLayer.bounds.height)
}
}
Bu fonksiyonların her biri bir layer’ın içeriğini belirler. Çizim için merkez noktaları centerPoints dizisinden alınır. Her fonksiyonda kullanılan ve layer’ları oluşturan _textLayer, _imageLayer ve _customImageLayer fonksiyonları vardır. Bu fonksiyonlar kullanılarak oluşturulan layer’lar currentIndex değerine göre gösterilir veya gizlenir.
_textLayer() fonksiyonu, .numeric stepper bileşenindeki sayıların olduğu layer’ı oluşturur.
_imageLayer() fonksiyonu, tamamlanan adımlardaki done ikonunun olduğu layer’ı oluşturur.
Son olarak, _customImageLayer() fonksiyonu, eğer stepper .Icon görünümündeyse her adımdaki ikonların olduğu layer’ı oluşturur. Eğer for döngüsündeki index, currentIndex değerinden büyük veya eşitse imageLayer gizlenir ve tam tersi durumda görünür olur.
Her bir render fonksiyonunun sonunda, layer’ın frame yapısı centerPoints içindeki koordinatlara göre render edilir.
private func _textLayer(atIndex index: Int) -> CATextLayer {
var textLayer: CATextLayer
if let _textLayer = _textLayers[index] {
textLayer = _textLayer
} else {
textLayer = CATextLayer()
_textLayers[index] = textLayer
}
layer.addSublayer(textLayer)
return textLayer
}
private func _imageLayer(atIndex index: Int) -> CALayer {
var imageLayer: CALayer
if let _imageLayer = _imageLayers[index] {
imageLayer = _imageLayer
} else {
imageLayer = CALayer()
// imageLayer.contents = UIImage(systemName: "star.fill")?.cgImage
imageLayer.contents = UIImage(named: "doneStep")?.cgImage
_imageLayers[index] = imageLayer
}
layer.addSublayer(imageLayer)
return imageLayer
}
private func _customImageLayer(atIndex index: Int) -> CALayer {
var customImagelayer: CALayer
let uncheckedIconColor = UIColor(red: 156.0/255.0, green: 145.0/255.0, blue: 158.0/255.0, alpha: 1.0)
let checkedIconColor = UIColor.orange
if let _customImageLayer = _customImageLayers[index] {
customImagelayer = _customImageLayer
} else {
customImagelayer = CALayer()
var stepIcon = UIImage(named: stepperIcons[index] ?? "")
if index <= currentIndex {
customImagelayer.contents = stepIcon?.withColor(checkedIconColor)
} else {
customImagelayer.contents = stepIcon?.withColor(uncheckedIconColor)
}
_customImageLayers[index] = customImagelayer
}
layer.addSublayer(customImagelayer)
return customImagelayer
}
_shapePath() fonksiyonu
_draw() fonksiyonunun tüm parçaları bir araya getirdiğimiz yer olduğundan bahsetmiştik. Bu parçaların en önemlisi _shapePath() fonksiyonudur. Bu fonksiyonda, UIBezierPath() kullanarak yaptığımız tüm hesaplamaları çiziyoruz.
private func _shapePath(_ centerPoints: [CGPoint], aRadius: CGFloat, aLineHeight: CGFloat) -> CALayer {
let nbPoint = centerPoints.count
for i in 0..<nbPoint{
let centerPoint = centerPoints[i]
let shape: UIBezierPath
let fillLayer = CAShapeLayer()
shape = UIBezierPath(roundedRect: CGRect(x: centerPoint.x - aRadius, y: centerPoint.y - aRadius, width: 2.0 * aRadius, height: 2.0 * aRadius), cornerRadius: aRadius)
/// Background color set of step points.
switch stepperType {
case .Icon:
if i <= currentIndex {
fillLayer.path = shape.cgPath
fillLayer.fillColor = UIColor(red: 214.0/255.0, green: 204.0/255.0, blue: 178.0/255.0, alpha: 0.8).cgColor
}else{
fillLayer.path = shape.cgPath
fillLayer.fillColor = UIColor(red: 231.0/255.0, green: 229.0/255.0, blue: 232.0/255.0, alpha: 1.0).cgColor
}
case .Numeric:
if i <= currentIndex{
if i != currentIndex {
fillLayer.path = shape.cgPath
fillLayer.fillColor = backgroundShapeColor.cgColor
} else {
fillLayer.path = shape.cgPath
fillLayer.fillColor = UIColor.orange.cgColor
}
}else{
fillLayer.path = shape.cgPath
fillLayer.fillColor = backgroundShapeColor.cgColor
}
}
layer.addSublayer(fillLayer)
let shapeLayer = CAShapeLayer()
if nbPoint > 1 && i != nbPoint - 1{
//design the path
let path = UIBezierPath()
let nextPoint = centerPoints[i + 1]
path.move(to: CGPoint(x: centerPoint.x + aRadius + 10, y: centerPoint.y))
path.addLine(to: CGPoint(x: nextPoint.x - aRadius - 10, y: nextPoint.y))
//design path in layer
shapeLayer.path = path.cgPath
shapeLayer.strokeColor = backgroundShapeColor.cgColor
shapeLayer.lineWidth = aLineHeight
}
layer.addSublayer(shapeLayer)
}
return layer
}
Fonksiyon parametre olarak çizilecek noktaları içeren centerPoints dizisini, iki adım arasında çizilecek çizginin yüksekliğini içeren lineHeight değişkenini ve adım yarıçapını içeren radius değişkenini alır. Bir for döngüsü içinde, her bir adım için UIBezierPath ile bir çizim yapılır. Ardından çizimin içi stepper tipine göre renklendirilir.
Son kısımda, shapeLayer içindeki iki adım arasına çizgi çizilerek çizim tamamlanır. Ardından hepsini içeren layer, draw() fonksiyonunda kullanılmak üzere döndürülür.
Ekran Tasarımlarında Uygulama
Arayüzünüzü storyboard veya programatik olarak oluşturduktan sonra bir UIView oluşturun. Bu view, StepperView sınıfından bir nesne olmalıdır. Uygulamak istediğiniz adım sayısına göre genişlik kısıtlamasını (width constraint) güncellemelisiniz. 3 adımlı bir stepper için ideal genişlik 220 point olarak alınabilir.


Bileşeni ViewController içinde tanımladıktan sonra, numberOfPoints ve currentIndex değerlerini istediğiniz gibi verebilirsiniz. Stepper tipini seçebilirsiniz. Eğer stepper bileşenini .icon görünümünde kullanmak isterseniz, ikon isimlerini string olarak stepperIcons dictionary yapısına verebilirsiniz.

Genel Bakış
Stepper bileşenini isteklerinize göre özelleştirebilirsiniz. Bunun için kilit noktalar renderTextImages, renderImageIndexes ve renderCustomImageIndexes fonksiyonları olacaktır. Stepper üzerindeki ikonlar bu fonksiyonlar üzerinden güncellenebilir.
Yine, çizimlerin yapıldığı yer _shapePath fonksiyonu olduğundan, renk değişimleri ve güncellemeler için bu fonksiyona odaklanabilirsiniz.
Bu makalede, UIBezierPath ve Core Animation layer’larını kullanarak Swift ile bir stepper bileşenini nasıl inşa edebileceğimizi açıklamaya çalıştım. Umarım faydalı ve anlaşılır bir makale olmuştur. Demo projeye aşağıdaki GitHub hesabımdan erişebilirsiniz. Sonraki makalede görüşmek üzere! 👋🏻
Yazar: Oğuzhan Kertmen