私は UI コンポーネントを構築するのが大好きです。構築した後にそれを見て使用するのは、ある種の美的喜びを与えてくれます。これは、MacOS/iOS エコシステム全体に対する私の愛情とも結びついています。最近、私はUIKit
を使用してカスタム プログレス バーを作成するという任務を与えられました。それは大したことではありませんが、おそらくiOS 開発者の 99% はキャリアのある時点でゼロから開発したことがあります。私の場合、0 から 1 までの値の範囲に基づいたアニメーションを含むCALayer
をいくつか作成するだけではなく、もう少しやりたかったのです。進行状況バーが必要な多くのシナリオに適したソリューションを目指していました。 。
Apple の世界の UI コンポーネントが次のようなものであった時代に戻りたいと思います。
UI デザインの進化を振り返ると、2014 年の iOS 7 の導入により、Apple の世界の UI 要素がフラット デザインを採用してからほぼ 10 年が経ちました。そこで、Mac OS と iOS の初期のバージョンへの懐かしさを感じながら、新しいデザインを作成してみましょう。美しいプログレスバーも一緒に。
UIKit
の使用は楽しいが、CALayers の操作方法がまだわからない場合は、この記事が役に立ちます。ここでは、典型的なレイアウト、アニメーション、描画の課題を取り上げ、その過程で役立つ洞察と解決策を提供します。
進行状況バーは簡単に見えるかもしれませんが、詳細を詳しく見てみると、多くのサブレイヤーが必要であることがわかるでしょう。
ここではCALayers
について詳しく説明するつもりはありません。 CALayers
何か、およびその仕組みについて簡単に説明したこの記事をご覧ください: https://hackernoon.com/rolling-numbers-animation-using-only-calayers 。
それでは、それを小さな層に分けてみましょう。
背景レイヤーには 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]
値に基づいてアニメーション化された幅を必要とするレイヤーで採用できるプロトコルを開発する必要があるようです。
プロトコルでは、アニメーションを使用してレイヤーの幅を変更する方法と、アニメーションを使用せずにレイヤーの幅を変更する方法の 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 つの主要部分、 ProgressMaskLayer
とProgressBackgroundLayer
を初期化します。 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 } }
ご覧のとおり、それほど複雑ではありません。 colorSpace
、 components
( red
、 green
、 blue
、および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
の初期化子は、グラデーションのborderWidth
とUIColor
の配列を受け入れます。グラデーションの色を設定し、境界線の幅を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
) を使用して、 shimmeringLayer
、 progressMaskLayer
、およびglareLayer
の 3 つのレイヤーのアニメーションを同期します。各レイヤーの幅は、指定された期間にわたって、指定されたアニメーション カーブを使用して、計算された幅 (デザインに合わせてshimmeringLayer
とglareLayer
のWIDTH_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 デザインにおいては、シンプルさと懐かしさが美しく共存し、過去と現在の橋渡しができるということを痛感させられました。
コーディングを楽しんでください!