UI bileşenleri oluşturmayı seviyorum. Bir yapıyı yaptıktan sonra bunları görmek, kullanmak bana bir nevi estetik haz veriyor. Bu aynı zamanda genel olarak MacOS/iOS ekosistemine olan sevgimle de birleşiyor. Son zamanlarda UIKit
kullanarak özel bir ilerleme çubuğu oluşturmakla görevlendirildim. Bu çok da önemli değil ama muhtemelen iOS geliştiricilerinin %99'u kariyerlerinin bir noktasında sıfırdan bir tane oluşturmuştur. Benim durumumda, 0'dan 1'e kadar bir değer aralığını temel alan animasyonlara sahip birkaç CALayer
oluşturmaktan biraz daha fazlasını yapmak istedim. İlerleme çubuğunun gerekli olduğu birçok senaryoya uygun olacak bir çözüm hedefliyordum. .
Kendimi Apple dünyasındaki kullanıcı arayüzü bileşenlerinin şöyle göründüğü günlere atmak istiyorum:
Kullanıcı arayüzü tasarımının evrimini düşünürsek, 2014 yılında iOS 7'nin piyasaya sürülmesiyle Apple dünyasındaki kullanıcı arayüzü öğelerinin düz bir tasarımı benimsemesinden bu yana neredeyse on yıl geçti. Öyleyse, Mac OS ve iOS'un önceki sürümleri için nostaljinin tadını çıkaralım ve yeni tasarımlar oluşturalım. birlikte güzel bir ilerleme çubuğu.
UIKit
kullanmaktan hoşlanıyorsanız ancak CALayer'ları nasıl yöneteceğinizden hala emin değilseniz, bu makale sizin için yararlı olacaktır. Burada tipik düzen, animasyon ve çizim zorluklarını ele alacağız ve yol boyunca size yardımcı olacak bilgiler ve çözümler sunacağız.
İlerleme çubuğu basit görünebilir, ancak ayrıntılara derinlemesine inersek, bunun çok sayıda alt katman gerektirdiğini anlayacaksınız.
Burada CALayers
ayrıntılı olarak ele almayacağım. CALayers
ne olduğunu ve nasıl çalıştıklarını kısaca anlattığım bu makaleye göz atabilirsiniz: https://hackernoon.com/rolling-numbers-animation-using-only-calayers .
Öyleyse onu küçük katmanlara ayıralım:
Arka plan katmanının iki ek alt katmanı vardır:
Düzgün bir şekilde yapılandıralım:
RetroProgressBar [UIView] |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer] |--Progress Background Layer [CALayer] |--Animated Mask Layer [CALayer] |--Glare [CALayer] |--Shimering [CALayer]
Kısaca şöyle çalışmalıdır: İlerleme Çubuğu 0,0 ile 1,0 arasında bir değere tepki vermelidir. Değer değiştiğinde katmanın genişliğini bu değere göre ayarlamamız gerekir:
bounds.width * value
Bu basit hesaplamayı kullanarak CATransaction
kullanarak genişliği değiştirebiliriz. Ancak bunu yapmadan önce değer değiştiğinde hangi katmanların değişmesi gerektiğini anlamamız gerekiyor. Yapıyı yeniden gözden geçirelim ve değer değişikliklerine yanıt vermesi gereken katmanları belirleyelim.
RetroProgressBar [UIView] |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer] |--Progress Background Layer [CALayer] |--Animated Mask Layer [CALayer] : [Animated Width] |--Glare [CALayer] : [Animated Width] |--Shimering [CALayer] : [Animated Width]
Görünüşe göre, değere dayalı olarak animasyonlu genişliğe ihtiyaç duyan katmanların benimseyebileceği bir protokol geliştirmemiz gerekiyor.
Protokolde yalnızca iki yönteme ihtiyacımız var: katmanın genişliğini animasyonla ve animasyon olmadan değiştirin.
protocol ProgressAnimatable: CALayer { /// Sets the layer's width instantly to the specified value. func setToWidth(_ width: CGFloat) /// Animates the layer's width to the specified value. func animateToWidth(_ width: CGFloat, duration: TimeInterval, animationType: CAMediaTimingFunctionName, completion: (() -> Void)?) }
Uygulama:
extension ProgressAnimatable { func setToWidth(_ width: CGFloat) { guard width >= 0 else { return } removeAllAnimations() CATransaction.begin() CATransaction.setDisableActions(true) self.bounds.size.width = width CATransaction.commit() } func animateToWidth(_ width: CGFloat, duration: TimeInterval, animationType: CAMediaTimingFunctionName = .easeInEaseOut, completion: (() -> Void)? = nil) { guard width >= 0, width != self.bounds.width else { completion?() return } let animation = CABasicAnimation(keyPath: "bounds.size.width") animation.fromValue = bounds.width animation.toValue = width animation.duration = duration animation.fillMode = .forwards animation.isRemovedOnCompletion = false animation.timingFunction = CAMediaTimingFunction(name: animationType) CATransaction.begin() CATransaction.setCompletionBlock { self.bounds.size.width = width completion?() } self.add(animation, forKey: "widthChange") CATransaction.commit() } }
Her iki yöntem de CATransaction
kullanır. İlk yöntem tuhaf görünebilir çünkü animasyon olmadan yalnızca bir değeri değiştirmemiz gerekiyor. Peki CATransaction
neden gereklidir?
Temel bir CALayer
oluşturmayı ve herhangi bir animasyon olmadan genişliğini veya yüksekliğini değiştirmeyi deneyin. Bir yapıyı çalıştırdığınızda CALayer
bir animasyonla değiştiğini fark edeceksiniz. Bu nasıl mümkün olabilir? Çekirdek Animasyonda, katman özelliklerinde (sınır, konum, opaklık vb. gibi) yapılan değişiklikler genellikle o özellikle ilişkili varsayılan eyleme göre canlandırılır. Bu varsayılan davranışın amacı, her özellik değişikliği için açık animasyonlara ihtiyaç duymadan yumuşak görsel geçişler sağlamaktır.
Bu, CALayer
eylemleri açıkça devre dışı bırakmamız gerektiği anlamına gelir. CATransaction.setDisableActions(true)
ayarını yaparak, katman genişliğinin herhangi bir ara animasyonlu geçiş olmadan anında yeni değere güncellenmesini sağlarsınız.
İkinci yöntem, ProgressAnimatable
protokolüne uygun herhangi bir katmanın genişliğini canlandırmak için esnek ve yeniden kullanılabilir bir yol sunar. Animasyonun süresi ve ilerleme hızı üzerinde kontrol sağlar ve tamamlama eylemi için bir seçenek içerir. Bu, onu UIKit çerçevesi içindeki çeşitli animasyon ihtiyaçları için çok uygun hale getirir.
Bu protokole uyacak adaylarımız üç katman olacaktır: Animasyonlu Maske Katmanı, Parlama Katmanı ve Parıltılı Katman. Devam edelim ve uygulayalım.
Artık protokolümüze uyma zamanı geldi ve bu kurulumdaki en önemli katman genel CAShapeLayer
olacak. Bu maskeye neden ihtiyacımız var? Neden sadece animasyonlu genişliğe sahip bir CALayer
olamıyor? Bir iOS Mühendisi olarak, özellikle de ön uç geliştirmeye odaklanan biri olarak, genellikle bileşenleriniz için potansiyel kullanım durumlarını tahmin etmeniz gerekir. Benim örneğimde bir İlerleme Arka Plan Katmanı var. Bu animasyonlu bir CALayer
değil ama ya CAReplicatorLayer
gibi bir şeyle animasyonlu olsaydı? Performans konusunda endişeleriniz varsa, muhtemelen böyle bir katmanın bir kez oluşturulması ve ardından animasyonunun gerçekleştirilmesi gerektiğini düşünürsünüz. Bu yüzden bu maske son derece faydalı olabilir. Çeşitli animasyon senaryoları için gereken esnekliği sunarken aynı zamanda verimli görüntü oluşturmaya olanak tanır.
final class ProgressMaskLayer: CAShapeLayer, ProgressAnimatable { override init() { super.init() backgroundColor = UIColor.black.cgColor anchorPoint = CGPoint(x: 0, y: 0.5) } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
Bu satıra bir göz atalım:
anchorPoint = CGPoint(x: 0, y: 0.5)
Bu çizgi İlerleme Çubuğumuz için çok önemlidir. Varsayılan olarak, CALayers
ortalanmıştır ve anchorPoint
genel olarak varsayıldığı gibi sol kenarın ortasında değil, katmanın merkezinde bulunur. anchorPoint
, katmandaki tüm dönüşümlerin gerçekleştiği noktadır. İlerleme çubuğu olarak kullanılan bir katman için bu ayar, genellikle katmanın genişliği değiştiğinde, anchorPoint
itibaren her iki yönde de eşit şekilde genişleyeceği anlamına gelir. Ancak, anchorPoint
sol kenarın ortasına ayarlarsak, katman yalnızca sağa doğru genişlerken sol kenar yerinde sabit kalır. Bu davranış, ilerleme çubuğu için gereklidir ve çubuğun büyümesinin her iki taraftan ziyade bir taraftan görünmesini sağlar.
private let progressMaskLayer = ProgressMaskLayer() private let progressBackgroundLayer = ProgressBackgroundLayer() public init() { super.init(frame: .zero) layer.addSublayer(progressMaskLayer) layer.addSublayer(progressBackgroundLayer) progressBackgroundLayer.mask = progressMaskLayer }
Yukarıdaki kod parçasında, İlerleme Çubuğunun iki ana bölümünü başlatıyoruz: ProgressMaskLayer
ve ProgressBackgroundLayer
. Şimdi ikincisine bir göz atalım.
final class ProgressBackgroundLayer: CAGradientLayer { override init() { super.init() locations = [0, 0.5, 1] } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() colors = [ backgroundColor?.lightened(by: 0.3), backgroundColor, backgroundColor?.darkened(by: 0.5) ].compactMap({ $0 }) } }
Gördüğünüz gibi üç renkten oluşan bir CAGradientLayer
. Bu uygulama, enjekte edilen rengi dikey bir degradeye dönüştürerek ilerleme çubuğunun görünürlüğünü artırır ve ona daha hacimli bir görünüm kazandırır.
Bunu mümkün kılmak için CGColor
eklentisinde iki yöntem hazırladım. Eğlenmek için CGColor
tekrar UIColor
dönüştürmeyi değil, yalnızca CGColor
alanında oynamayı seçtim:
extension CGColor { func darkened(by value: CGFloat) -> CGColor { guard let colorSpace = self.colorSpace, let components = self.components, colorSpace.model == .rgb, components.count >= 3 else { return self } let multiplier = 1 - min(max(value, 0), 1) let red = components[0] * multiplier let green = components[1] * multiplier let blue = components[2] * multiplier let alpha = components.count > 3 ? components[3] : 1.0 return CGColor( colorSpace: colorSpace, components: [red, green, blue, alpha] ) ?? self } func lightened(by value: CGFloat) -> CGColor { guard let colorSpace = self.colorSpace, let components = self.components, colorSpace.model == .rgb, components.count >= 3 else { return self } let red = min(components[0] + value, 1.0) let green = min(components[1] + value, 1.0) let blue = min(components[2] + value, 1.0) let alpha = components.count > 3 ? components[3] : 1.0 return CGColor( colorSpace: colorSpace, components: [red, green, blue, alpha] ) ?? self } }
Gördüğünüz gibi çok karmaşık değil. Sadece colorSpace
, components
( red
, green
, blue
ve alpha
) ve model
almamız gerekiyor. Bu, değere bağlı olarak yeni bir renk hesaplayabileceğimiz anlamına gelir: daha koyu veya daha açık.
İlerleme Çubuğumuzu daha 3 boyutlu hale getirmeye doğru ilerleyelim. Şimdi Glare adını vereceğimiz başka bir katman eklememiz gerekiyor.
Bu basit bir uygulamadır ancak ProgressAnimatable
protokolüne uygun olmalıdır.
final class ProgressGlareLayer: CALayer, ProgressAnimatable { override init() { super.init() backgroundColor = UIColor.white.withAlphaComponent(0.3).cgColor anchorPoint = CGPoint(x: 0, y: 0.5) } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
İşleri basitleştirmek için burada arka plan renginin yalnızca 0,3 alfa ile beyaz olmasına ihtiyacımız var.
Bazılarının, sürüm 6'dan önceki MacOS X veya iOS'un bunun gibi parıldayan efektlere sahip kullanıcı arayüzü bileşenlerine sahip olmadığını söyleyebileceğini biliyorum. Ama dediğim gibi gerçek bir hikayeden esinlenilmiş bir film gibi bu. Bu parıltı, 3D efektini vurgular ve onu daha görünür hale getirir.
final class ShimmeringLayer: CAGradientLayer, ProgressAnimatable { let glowColor: UIColor = UIColor.white private func shimmerAnimation() -> CABasicAnimation { let animation = CABasicAnimation(keyPath: "locations") animation.fromValue = [-1.0, -0.5, 0.0] animation.toValue = [1.0, 1.5, 2.0] animation.duration = 1.5 animation.repeatCount = Float.infinity return animation } private func opacityAnimation() -> CABasicAnimation { let animation = CABasicAnimation(keyPath: "opacity") animation.fromValue = 1.0 animation.toValue = 0.0 animation.duration = 0.1 // Quick transition to transparent animation.beginTime = shimmerAnimation().duration animation.fillMode = .forwards animation.isRemovedOnCompletion = false return animation } private func animationGroup() -> CAAnimationGroup { let pauseDuration = 3.0 // Duration of pause between shimmering let shimmerAnimation = shimmerAnimation() let opacityAnimation = opacityAnimation() let animationGroup = CAAnimationGroup() animationGroup.animations = [shimmerAnimation, opacityAnimation] animationGroup.duration = shimmerAnimation.duration + pauseDuration animationGroup.repeatCount = Float.infinity return animationGroup } override init() { super.init() setupLayer() } override init(layer: Any) { super.init(layer: layer) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() startAnimation() } private func setupLayer() { let lightColor = UIColor.white.withAlphaComponent(0.7).cgColor let darkColor = UIColor.white.withAlphaComponent(0).cgColor colors = [ darkColor, lightColor, darkColor ] anchorPoint = CGPoint(x: 0, y: 0.5) locations = [0.0, 0.5, 1.0] startPoint = CGPoint(x: 0.0, y: 0.5) endPoint = CGPoint(x: 1.0, y: 0.5) shadowColor = glowColor.cgColor shadowRadius = 5.0 shadowOpacity = 1 shadowOffset = .zero } private func startAnimation() { if animation(forKey: "shimmerEffect") == nil { let animationGroup = animationGroup() add(animationGroup, forKey: "shimmerEffect") } } }
Burada, CAAnimationGroup
birleştirilmiş iki animasyona (Opaklık ve Konum) ihtiyacımız var. Ayrıca CAAnimationGroup
kullanmak, animasyon oturumları arasında bir duraklama ayarlamamıza olanak tanır. Bu özellik oldukça kullanışlıdır çünkü istemciler de bu özelliği kontrol etmek isteyebilir.
Resimde görebileceğiniz gibi bu daha fazla 3D efekt ekliyor ancak bu yeterli değil. İlerleyelim.
Animasyonlu İlerleme Çizgisinin altında yer alan statik arka plan katmanımızla çalışmamız gerekiyor. Hatırlarsanız orada iki alt katmanımız daha var: İç Gölge ve Kenar Gradyanı.
|--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer]
Bu iki katmanın, muhtemelen kullanıcı arayüzü eğilimleri nedeniyle, birkaç yıl önce arama sorgularında oldukça popüler olduğunu söyleyebilirim. Gelin kendi versiyonlarımızı oluşturalım ve gelecek nesiller için bu yazımızda ölümsüzleştirelim :)
Sınıf, kullanıcı arayüzü öğelerine derinlik katmanın görsel olarak çekici bir yolu olabilecek iç gölge efektine sahip bir katman oluşturmak üzere tasarlanmıştır.
final class InnerShadowLayer: CAShapeLayer { override init() { super.init() masksToBounds = true shadowRadius = 3 shadowColor = UIColor.black.cgColor shadowOffset = CGSize(width: 0.0, height: 1.0) shadowOpacity = 0.5 } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() // Creates a path for the shadow that extends slightly outside the bounds of the layer. let shadowPath = UIBezierPath(roundedRect: bounds.insetBy(dx: -5, dy: -5), cornerRadius: cornerRadius) // Creates a cutout path that is the inverse of the layer's bounds. let cutoutPath = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).reversing() shadowPath.append(cutoutPath) self.shadowPath = shadowPath.cgPath } }
Bir CAShapeLayer
ihtiyacımız var çünkü shadowPath
ayarlamamız gerekiyor. Bunun için insetBy
frombounds kullanarak katman sınırlarının biraz dışına uzanan gölge için bir yol oluşturmamız gerekiyor. Ve sonra başka bir UIBezierPath
- katmanın sınırlarının tersi olan bir kesme yolu.
Resimde zaten neredeyse orada. Ancak bizi MacOS X Leopard'a geri getirecek son katmana ihtiyacımız var.
Bir katmanın etrafında degrade kenarlık oluşturmak için tasarlanmış CAGradientLayer
bir alt sınıfıdır. Kenarlık efektini elde etmek için CAShapeLayer
maske olarak kullanır. shapeLayer
kontur rengiyle (başlangıçta siyah) ve dolgu rengi olmadan yapılandırılmıştır ve BorderGradientLayer
için maske olarak kullanılır. Bu kurulum, degradenin yalnızca shapeLayer
konturunun olduğu yerde görünmesini sağlayarak etkili bir degrade kenarlığı oluşturur.
final class BorderGradientLayer: CAGradientLayer { let shapeLayer: CAShapeLayer = { let layer = CAShapeLayer() layer.strokeColor = UIColor.black.cgColor // Temporary color layer.fillColor = nil return layer }() init(borderWidth: CGFloat, colors: [UIColor]) { super.init() self.mask = shapeLayer self.colors = colors.map { $0.cgColor } self.setBorderWidth(borderWidth) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() shapeLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: cornerRadius).cgPath } func setBorderWidth(_ borderWidth: CGFloat) { self.shapeLayer.lineWidth = borderWidth } }
BorderGradientLayer
başlatıcısı, degrade için bir borderWidth
ve bir UIColor
dizisini kabul eder. Degrade renklerini ayarlar, kenarlık genişliğini shapeLayer
uygular ve degradeyi kenarlık alanıyla sınırlamak için shapeLayer
maske olarak kullanır.
layoutSublayers
yöntemi, shapeLayer
yolunun katmanın sınırları ve köşe yarıçapıyla eşleşecek şekilde güncellenmesini sağlayarak kenarlığın katmana doğru şekilde uymasını sağlar. setBorderWidth
yöntemi, başlatma sonrasında kenarlığın genişliğinin dinamik olarak ayarlanmasına olanak tanır.
Çok daha iyi. Tamam. Artık görsellik yapmayı bırakalım ve mantık üzerinde çalışalım.
Artık tüm bu katmanları birleştirip hayata geçirmenin zamanı geldi.
private lazy var backgroundLayer = BackgroundLayer( borderWidth: borderWidth, colors: borderColors ) private let glareLayer = ProgressGlareLayer() private let shimmeringLayer = ShimmeringLayer() private let progressMaskLayer = ProgressMaskLayer() private let progressBackgroundLayer = ProgressBackgroundLayer() public init() { super.init(frame: .zero) layer.backgroundColor = UIColor.white.cgColor layer.masksToBounds = true layer.addSublayer(progressMaskLayer) layer.addSublayer(progressBackgroundLayer) layer.insertSublayer(backgroundLayer, at: 0) progressBackgroundLayer.addSublayer(shimmeringLayer) progressBackgroundLayer.addSublayer(glareLayer) progressBackgroundLayer.mask = progressMaskLayer }
Genişliği değişmesi gereken tüm katmanları animasyonla nasıl ele alacağımıza bir göz atalım:
public func setValueWithAnimation(_ value: Double, duration: TimeInterval = 1.0, animationType: CAMediaTimingFunctionName = .easeInEaseOut, completion: (() -> Void)? = nil) { let newWidth = calculateProgressWidth(value) let animationGroup = DispatchGroup() animationGroup.enter() shimmeringLayer.animateToWidth( newWidth - WIDTH_ANIMATABLE_INSET, duration: duration, animationType: animationType, completion: animationGroup.leave ) animationGroup.enter() progressMaskLayer.animateToWidth( newWidth, duration: duration, animationType: animationType, completion: animationGroup.leave ) animationGroup.enter() glareLayer.animateToWidth( newWidth - WIDTH_ANIMATABLE_INSET, duration: duration, animationType: animationType, completion: animationGroup.leave ) animationGroup.notify(queue: .main) { completion?() } }
Öncelikle bir değerden genişlik hazırlamamız gerekiyor. Değerin 0'dan küçük ve 1'den büyük olması gerekiyor. Ayrıca olası kenar genişliğini de hesaba katmamız gerekiyor.
fileprivate func normalizeValue(_ value: Double) -> Double { max(min(value, 1), 0) } private func calculateProgressWidth(_ value: Double) -> CGFloat { let normalizedValue = normalizeValue(value) let width = bounds.width * normalizedValue - borderWidth return width }
Doğru 3D efekti için parlama ve parıltılı katmanlar sağ ve sol taraftan biraz dolgulu olmalıdır:
newWidth - WIDTH_ANIMATABLE_INSET
Son olarak yöntem, üç katmanın animasyonlarını senkronize etmek için bir animasyon grubu ( DispatchGroup
) kullanır: shimmeringLayer
, progressMaskLayer
ve glareLayer
. Her katmanın genişliği, belirtilen süre boyunca ve belirtilen animasyon eğrisiyle hesaplanan genişliğe göre canlandırılır ( shimmeringLayer
ve glareLayer
için WIDTH_ANIMATABLE_INSET
ile tasarıma uyacak şekilde ayarlanır).
Animasyonlar DispatchGroup
kullanılarak koordine edilir, böylece tüm animasyonların aynı anda başlaması ve tamamlama kapanışının yalnızca tüm animasyonlar bittikten sonra çağrılması sağlanır. Bu yöntem, ilerleme çubuğunun değerini, çeşitli dekoratif katmanları boyunca düzgün, senkronize bir animasyonla güncellemek için görsel olarak çekici bir yol sağlar.
En son sürüm Swift Paketi ve Pod olarak mevcuttur. Yani buradan alabilirsiniz Retro Progress Bar deposu . Bu arada, katkılarınızı bekliyoruz!
RetroProgressBar'ın bir örneğini oluşturun. Herhangi bir UIView'da yaptığınız gibi bunu görünüm hiyerarşinize ekleyebilirsiniz.
let progressBar = RetroProgressBar()
Özelleştirme:
progressBar.progressColor = UIColor.systemBlue progressBar.cornerRadius = 5.0 progressBar.borderWidth = 2.0 progressBar.borderColors = [UIColor.white, UIColor.gray]
Değeri animasyonla değiştirmek için:
progressBar.setValueWithAnimation(0.75, duration: 1.0, animationType: .easeInEaseOut) { print("Animation Completed") }
Animasyonsuz:
progressBar.setValue(0.5)
Bu eski tarz ilerleme çubuğunu yeniden yaratmak, çok özlediğim estetiğe nostaljik bir dalış oldu. UIKit ve CALayer
ile çalışmak bana geliştiriciler olarak araçlarımızın çok yönlülüğünü ve gücünü hatırlattı. Bu proje sadece teknik uygulamayla ilgili değildi; eski tasarımların eskimeyen cazibesi de dahil olmak üzere hâlâ her şeyi üretebildiğimizi kanıtlayan, sevilen bir tasarım çağına geri dönüş yolculuğuydu.
Bu deneyim, kullanıcı arayüzü tasarımında sadeliğin ve nostaljinin güzel bir şekilde bir arada var olabileceğini, geçmiş ile günümüz arasında köprü kurabileceğini hatırlattı.
Mutlu kodlama!