paint-brush
UI スローバック: CALayers を使用した iOS 用のレトロ プログレス バーの作成@maxkalik
680 測定値
680 測定値

UI スローバック: CALayers を使用した iOS 用のレトロ プログレス バーの作成

Max Kalik22m2023/12/17
Read on Terminal Reader

長すぎる; 読むには

このガイドでは、CALayers を使用してノスタルジックな雰囲気を醸し出す、iOS でのレトロなスタイルのプログレス バーの作成について説明します。各レイヤーの設計からアニメーションの実装まで、段階的なプロセスをカバーし、昔ながらの美学と最新の iOS 開発を組み合わせる洞察を提供します。この記事は、開発者や UI デザイナーにとって理想的なもので、独自の UI コンポーネントに関する技術的な深みと創造的なインスピレーションの両方を提供します。ソースコード: https://github.com/maxkalik/RetroProgressBar
featured image - UI スローバック: CALayers を使用した iOS 用のレトロ プログレス バーの作成
Max Kalik HackerNoon profile picture
0-item


私は UI コンポーネントを構築するのが大好きです。構築した後にそれを見て使用するのは、ある種の美的喜びを与えてくれます。これは、MacOS/iOS エコシステム全体に対する私の愛情とも結びついています。最近、私はUIKitを使用してカスタム プログレス バーを作成するという任務を与えられました。それは大したことではありませんが、おそらくiOS 開発者の 99% はキャリアのある時点でゼロから開発したことがあります。私の場合、0 から 1 までの値の範囲に基づいたアニメーションを含むCALayerをいくつか作成するだけではなく、もう少しやりたかったのです。進行状況バーが必要な多くのシナリオに適したソリューションを目指していました。 。


Apple の世界の UI コンポーネントが次のようなものであった時代に戻りたいと思います。


ソース: https://www.smashingmagazine.com/2010/08/free-wireframing-kits-ui-design-kits-pdfs-and-resources/


UI デザインの進化を振り返ると、2014 年の iOS 7 の導入により、Apple の世界の UI 要素がフラット デザインを採用してからほぼ 10 年が経ちました。そこで、Mac OS と iOS の初期のバージョンへの懐かしさを感じながら、新しいデザインを作成してみましょう。美しいプログレスバーも一緒に。


UIKitの使用は楽しいが、CALayers の操作方法がまだわからない場合は、この記事が役に立ちます。ここでは、典型的なレイアウト、アニメーション、描画の課題を取り上げ、その過程で役立つ洞察と解決策を提供します。

UIView から CALayers API まで。

進行状況バーは簡単に見えるかもしれませんが、詳細を詳しく見てみると、多くのサブレイヤーが必要であることがわかるでしょう。


レトロなプログレスバー


ここではCALayersについて詳しく説明するつもりはありません。 CALayers何か、およびその仕組みについて簡単に説明したこの記事をご覧ください: https://hackernoon.com/rolling-numbers-animation-using-only-calayers


それでは、それを小さな層に分けてみましょう。

  1. 一般的な背景レイヤー
  2. マスクレイヤー(アニメーション化可能な部分)
  3. 進行状況の背景レイヤー
  4. グレアレイヤー
  5. きらめくレイヤー


背景レイヤーには 2 つの追加の子レイヤーがあります。

  1. インナーシャドウレイヤー
  2. 境界線グラデーションレイヤー


適切に構造化してみましょう。


 RetroProgressBar [UIView] |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer] |--Progress Background Layer [CALayer] |--Animated Mask Layer [CALayer] |--Glare [CALayer] |--Shimering [CALayer]


簡単に説明すると、その動作方法は次のとおりです。プログレス バーは 0.0 から 1.0 の範囲の値に反応する必要があります。値が変更された場合は、この値に基づいてレイヤーの幅を調整する必要があります。


 bounds.width * value


この単純な計算を使用すると、 CATransactionを使用して幅を変更できます。ただし、これを行う前に、値が変更されたときにどのレイヤーを変更する必要があるかを理解する必要があります。構造を再確認して、値の変更に対応する必要があるレイヤーを特定してみましょう。


 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]


値に基づいてアニメーション化された幅を必要とするレイヤーで採用できるプロトコルを開発する必要があるようです。

ProgressAnimatable プロトコル

プロトコルでは、アニメーションを使用してレイヤーの幅を変更する方法と、アニメーションを使用せずにレイヤーの幅を変更する方法の 2 つだけが必要です。


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


実装:


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


どちらの方法でもCATransactionを使用します。最初の方法はアニメーションを使わずに値を変更するだけなので奇妙に見えるかもしれません。では、なぜCATransaction必要なのでしょうか?


基本的なCALayerを作成し、アニメーションを使用せずに幅または高さを変更してみてください。ビルドを実行すると、 CALayerアニメーションで変化することがわかります。これはどのようにして可能でしょうか? Core Animation では、レイヤー プロパティ (境界、位置、不透明度など) への変更は、通常、そのプロパティに関連付けられたデフォルトのアクションに基づいてアニメーション化されます。このデフォルトの動作は、プロパティの変更ごとに明示的なアニメーションを必要とせずに、視覚的にスムーズな遷移を提供することを目的としています。


これは、 CALayerのアクションを明示的に無効にする必要があることを意味します。 CATransaction.setDisableActions(true)を設定すると、中間のアニメーション遷移なしで、レイヤーの幅が即座に新しい値に更新されます。


2 番目の方法は、 ProgressAnimatableプロトコルに準拠して任意のレイヤーの幅をアニメーション化する柔軟で再利用可能な方法を提供します。これにより、アニメーションの長さとペースを制御でき、完了アクションのオプションが含まれます。これにより、UIKit フレームワーク内のさまざまなアニメーションのニーズに適しています。


このプロトコルに準拠する候補は、アニメーション マスク レイヤー、グレア レイヤー、およびシマー レイヤーの 3 つのレイヤーになります。早速実装してみましょう。

プログレスマスクレイヤー

プロトコルに準拠する時期が来ました。このセットアップで最も重要なレイヤーは、一般的なCAShapeLayerになります。なぜこのマスクが必要なのでしょうか?なぜアニメーション化された幅を持つCALayerにすることができないのでしょうか? iOS エンジニア、特にフロントエンド開発に重点を置くエンジニアとして、コンポーネントの潜在的なユースケースを予測する必要があることがよくあります。私のインスタンスには、Progress Background Layer があります。これはアニメーション化されたCALayerではありませんが、 CAReplicatorLayerのようなものでアニメーション化されたらどうなるでしょうか?パフォーマンスを懸念する場合は、そのようなレイヤーを 1 回レンダリングしてから、単純にアニメーションを実行する必要があると考えるでしょう。だからこそ、このマスクは非常に役に立つのです。これにより、さまざまなアニメーション シナリオに必要な柔軟性を提供しながら、効率的なレンダリングが可能になります。


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


この行を見てみましょう:


 anchorPoint = CGPoint(x: 0, y: 0.5)


この行はプログレスバーにとって重要です。デフォルトでは、 CALayers中央に配置され、 anchorPoint一般的に考えられている左端の中央ではなく、レイヤーの中央に配置されます。 anchorPoint 、レイヤーのすべての変換が発生するポイントです。プログレスバーとして使用されるレイヤーの場合、この設定は通常、レイヤーの幅が変更されると、 anchorPointから両方向に均等に拡張することを意味します。ただし、 anchorPoint左端の中央に調整すると、レイヤーは右方向にのみ拡張され、左端は所定の位置に固定されたままになります。この動作は進行状況バーにとって不可欠であり、バーの成長が両側ではなく片側から見えるようになります。


 private let progressMaskLayer = ProgressMaskLayer() private let progressBackgroundLayer = ProgressBackgroundLayer() public init() { super.init(frame: .zero) layer.addSublayer(progressMaskLayer) layer.addSublayer(progressBackgroundLayer) progressBackgroundLayer.mask = progressMaskLayer }


上のスニペットでは、プログレス バーの 2 つの主要部分、 ProgressMaskLayerProgressBackgroundLayerを初期化します。 2 番目を見てみましょう。


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


ご覧のとおり、これは 3 つの色を特徴とするCAGradientLayerです。この実装は、注入されたカラーを垂直グラデーションに変換し、進行状況バーの視認性を高め、よりボリュームのある外観を与えます。


勾配

これを可能にするために、 CGColor拡張機能に 2 つのメソッドを用意しました。楽しみのために、私はCGColor UIColorに変換し直すのではなく、 CGColorの領域内でのみプレイすることにしました。


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


ご覧のとおり、それほど複雑ではありません。 colorSpacecomponents ( redgreenblue 、およびalpha )、およびmodelを取得する必要があるだけです。これは、値に基づいて、新しい色 (より暗いか明るい色) を計算できることを意味します。


グレアレイヤー

プログレスバーをより 3D っぽくする方向に進みましょう。次に、別のレイヤーを追加する必要があります。これを「Glare」と呼びます。


これは単純な実装ですが、 ProgressAnimatableプロトコルに準拠する必要があります。


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


まぶしさあり


物事を単純にするために、ここでは背景色をアルファ 0.3 の白にするだけで済みます。


リトルナイスシマリング(シマリングレイヤー)

MacOS X やバージョン 6 より前の iOS には、このようなきらめくエフェクトを備えた UI コンポーネントがなかったという人もいるかもしれません。しかし、先ほども言いましたが、これは実話にインスピレーションを得た映画のようなものです。このきらめきが 3D 効果を強調し、より見やすくします。


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


ここでは、2 つのアニメーション (Opacity と Location) をCAAnimationGroupに結合する必要があります。また、 CAAnimationGroupを使用すると、アニメーション セッション間の一時停止を調整できます。クライアントはこのプロパティも制御したい場合があるため、この機能は非常に便利です。


きらめきあり


写真でわかるように、これにより 3D 効果がさらに追加されますが、十分ではありません。先に進みましょう。

背景レイヤー

アニメーション化されたプログレス ラインの下にある静的な背景レイヤーを操作する必要があります。思い出していただければ、そこには Inner Shadow と Border Gradient という 2 つの追加サブレイヤーがあります。


 |--General background [CALayer] |--Inner Shadow [CALayer] |--Border Gradient [CALayer]


おそらく UI のトレンドの影響で、これら 2 つのレイヤーは数年前に検索クエリで非常に人気があったと思います。独自のバージョンを作成して、この記事で将来の世代のためにそれらを永久に保存しましょう:)

インナーシャドウレイヤー

このクラスは、内側のシャドウ効果を持つレイヤーを作成するように設計されており、これは UI 要素に深みを加える視覚的に魅力的な方法となります。


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


shadowPath設定する必要があるため、 CAShapeLayerが必要です。そのためには、境界からのinsetByを使用して、レイヤーの境界のわずかに外側に広がるシャドウのパスを作成する必要があります。そして、もう 1 つのUIBezierPath 、つまりレイヤーの境界を逆にしたカットアウト パスです。


インナーシャドウ


図では、すでにほぼそこに来ています。ただし、MacOS X Leopard に戻る最後のレイヤーが必要です。

境界線グラデーションレイヤー

これは、レイヤーの周囲にグラデーション境界線を作成するように設計されたCAGradientLayerのサブクラスです。 CAShapeLayerをマスクとして使用して、境界線の効果を実現します。 shapeLayerストロークの色 (最初は黒) で構成され、塗りつぶしの色はなく、 BorderGradientLayerのマスクとして使用されます。この設定により、 shapeLayerストロークがある場所のみにグラデーションが表示され、効果的にグラデーションの境界線が作成されます。


 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の初期化子は、グラデーションのborderWidthUIColorの配列を受け入れます。グラデーションの色を設定し、境界線の幅をshapeLayerに適用し、 shapeLayerマスクとして使用してグラデーションを境界領域に制限します。


layoutSublayersメソッドは、 shapeLayerのパスがレイヤーの境界と角の半径に一致するように更新され、境界線がレイヤーに正しくフィットするようにします。 setBorderWidthメソッドを使用すると、初期化後に境界線の幅を動的に調整できます。


フチあり


ずっと良くなりました。わかりました。ビジュアルなものを作るのはやめて、ロジックに取り組みましょう。

みんなで一緒に

今度は、これらすべてのレイヤーを組み合わせて、それらに命を吹き込む時が来ました。


 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 }


アニメーションで幅を変更する必要があるすべてのレイヤーをどのように処理するかを見てみましょう。


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


まず、値から幅を準備する必要があります。値は 0 より小さく、1 より大きい必要があります。さらに、可能な境界線の幅を考慮する必要があります。


 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 }


正しい 3D 効果を得るには、グレア レイヤーときらめくレイヤーの右側と左側に少しパディングを付ける必要があります。


 newWidth - WIDTH_ANIMATABLE_INSET


最後に、メソッドはアニメーション グループ ( DispatchGroup ) を使用して、 shimmeringLayerprogressMaskLayer 、およびglareLayerの 3 つのレイヤーのアニメーションを同期します。各レイヤーの幅は、指定された期間にわたって、指定されたアニメーション カーブを使用して、計算された幅 (デザインに合わせてshimmeringLayerglareLayerWIDTH_ANIMATABLE_INSETによって調整) にアニメーション化されます。


アニメーションはDispatchGroupを使用して調整され、すべてのアニメーションが同時に開始され、すべてのアニメーションが終了した後にのみ完了クロージャが呼び出されることが保証されます。このメソッドは、さまざまな装飾レイヤーにわたるスムーズな同期アニメーションで進行状況バーの値を更新する、視覚的に魅力的な方法を提供します。

ソースコード

最新バージョンは、Swift パッケージおよびポッドとして存在します。したがって、ここからRetro Progress Bar リポジトリを取得できます。ところで、貢献は大歓迎です!

使用法

RetroProgressBar のインスタンスを作成します。他の UIView と同様に、ビュー階層に追加できます。


 let progressBar = RetroProgressBar()


カスタマイズ:


 progressBar.progressColor = UIColor.systemBlue progressBar.cornerRadius = 5.0 progressBar.borderWidth = 2.0 progressBar.borderColors = [UIColor.white, UIColor.gray]


アニメーションで値を変更するには:


 progressBar.setValueWithAnimation(0.75, duration: 1.0, animationType: .easeInEaseOut) { print("Animation Completed") }


アニメーションなし:


 progressBar.setValue(0.5)

最終的な考え

この古いスタイルのプログレスバーを再作成することは、とても懐かしい美学へのノスタルジックなダイビングでした。 UIKit とCALayerを使用することで、開発者としてのツールの多用途性と威力を思い出しました。このプロジェクトは技術的な実装だけを目的としたものではありません。それは愛されたデザインの時代へ戻る旅であり、古いデザインの時代を超越した魅力を含め、私たちは今でもあらゆるものを作ることができることを証明しました。


この経験は、 UI デザインにおいては、シンプルさと懐かしさが美しく共存し、過去と現在の橋渡しができるということを痛感させられました。


コーディングを楽しんでください!