[SwiftUI] インジケータ付き LongPress できる Button (ButtonStyle を使ったカスタマイズ)

SwiftUI2021

     

TAGS:

以前、LongPress できる Button の作り方を説明しましたが、アニメーションをつけてみます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 13 Beta5
  • Xcode 14.0 Beta6
  • iOS 16.0 beta

LongPressできる Button

以下のようなボタンを 最近よく見ます。ゲームの中で、ムービーをスキップしたりする時に使われています。
途中で心変わりした時には、時間経過前であれば 離すとキャンセルになるボタンです。

以前、LongPress "も"できるボタンは 以下の記事で 説明しました。
SwiftUI2021[SwiftUI] Longpress もできる Button の作り方

この記事のボタンは、LongPress "だけ” できるボタンであり、さらに LongPress 中はインジケータが表示されるボタンになっています。

当初、LongPress もできるボタンを 改良して作ろうと考えていましたが、色々と検討して、ButtonStyle を使った実装になってしまいました・・・

実装

以下、実装する機能の概要と使用するアーキテクチャ概要です。

機能概要

以下のような普通(?)の方針で作っていきます。
・長押し中に、Button の Border(的なもの) をアニメーションさせる
・特定時間を経過するまで押されていたら、アニメーションを終了して action を実行
・特定時間経過まえに離されたら、アニメーションを解除して終了

実質、Tap は受け付けない Button ということになります。実際には、Tap すると Border がアニメーションを開始しますが、特定時間経過を待たずに離してしまうと、キャンセル扱いになります。

MEMO
当初、TapGesture や LongPressGesture を駆使(?) して作ろうと考えていたのですが、調べていくと、ButtonStyle でカスタマイズする方が容易にできそうなので、方針を変更しました。

アーキテクチャ概要

独自 View として作る方法もありますが、ButtonStyle を使用して作っていきます。

振る舞いを変更したいので、ButtonStyle ではなく PrimitiveButtonStyle をベースに作っていきます。

PrimitiveButtonStyle/ButtonStyle

ButtonStyle を使用して作っていく時には、以下の2つのどちらかをベースにして作っていくことになります。
・PrimitiveButtonStyle
・ButtonStyle

# 振る舞いをカスタマイズするときには、PrimitiveButtonStyle を使うように書かれています。

2つの Style の違い(の1つ)は、押下の管理が、ButtonStyle 内で行われているか、実装側で処理しなければいけないかと言う点です。

なお、どちらも、以下の makeBody で実装する点は、同じです。

func makeBody(configuration: Self.Configuration) -> Self.Body

どちらの makeBody にも configuration が渡されてきますが、渡されてくる Configuration が異なります。

ButtonStyle では、ButtonStyleConfiguration という型の configuration が渡されてきます。
この型には、isPressed というプロパティがあり、ボタンが押下されているかの情報を取得することができます。

つまり、すでに ボタンが押下されているかどうかの判断はされていると言うことです。(ハイライトもされています)

Apple の ButtonStyleConfiguration についてのドキュメントは、こちら

PrimitiveButtonStyle では、PrimitiveButtonStyleConfiguration と言う型の configuration が渡されてきます。
この型には、isPressed というプロパティはありません。つまり、ボタンが押下されているかどうかは、(ButtonStyle の)実装側で判定しなければいけないと言うことです。
isPressed の代わりに、設定されている action を実行する trigger というメソッドが用意されていますので、ボタンが押下された(もしくはそれと同等)と判断した時に、呼び出すと言う実装を行う必要があります。

Apple の PrimitiveButtonStyleConfiguration についてのドキュメントは、こちら

詳細検討/実装

実装する ButtonStyle を LongPressButtonStyle として実装していくこととします。

持つべきプロパティは、以下です。
・長押し時間 (pressDuration: TimeInterval)
何秒押された時に action を実行するか
・色(color: Color)
インジケータ表示色
・progressIndicatorGenerator: ProgressIndicatorGenerator
インジケータ表示 closure
・progressManager: ProgressManager
タイマー管理/進捗管理用 class

インジケータ表示closure (ProgressIndicatorGenerator)

インジケータはアニメーションすることで、長押ししているユーザーにあとどれくらい押したままでいないといけないかがわかるという意味を持ちます。

インジケータの表示に Shape を使用し、ViewModifier である .trim を使用することで、アニメーションさせます。
具体的には、from:, to: で描画開始位置と終了位置を指定できるので、終了位置を移動させることで、アニメーションさせることができます。

Apple のドキュメントは、こちら

できれば インジケータは、ボタンのラベルに使用されている形に応じて使いたいのですが、基本的に、ビューの外部からは、ビューが使用する矩形の大きさしかわかりません。

ということで、ButtonStyle を使う側が 外部からインジケータを作成する closure を渡すこととしました。
使う側はどんな Label を Button に使っているか知っているはずですので、なんとか(?)できるはずです。

アニメーションできるようになっている必要があるのですが、closure に 進捗率(0~1) と色を渡すとそれに応じて、インジケータをアニメーション表示してくれることを期待しています。

こうしておくことで、使用者側で インジケータをカスタマイズすることも可能となります。

なお、新規 View ではなく ButtonStyle でカスタマイズしているので、Environment 経由で渡すことはできませんでした。
(厳密には、ButtonStyle から取得できる Environment に値をセットするシンプルな方法がありません。)

typealias していますが、ProgressIndicatorGenerator の具体的な型は以下です。

    typealias ProgressIndicatorGenerator = ((CGFloat, Color) -> Indicator)

インジケータ

ProgressIndicatorGenerator に準拠した関数をサンプルとして、3つ用意しています。
・defaultProgressIndicator
・roundedRectangleProgressIndicator
・circleProgressIndicator

それぞれ ContainerRelativeShape, ClockWiseRoundedRectangle, ClockWiseCircle という Shape を .trim を使って、0~1でアニメーションさせる 進捗インジケータになっています。

    func defaultProgressIndicator(_ progress: CGFloat,_ color: Color) -> some View {
        ContainerRelativeShape().trim(from: 0.0, to: progress).stroke(color, lineWidth: 3)
    }
    
    func roundedRectangleProgressIndicator(_ progress: CGFloat,_ color: Color) -> some View {
        ClockWiseRoundedRectangle(cornerRadius: 6).trim(from: 0.0, to: progress).stroke(color, lineWidth: 3)
    }
    
    func circleProgressIndicator(_ progress: CGFloat,_ color: Color) -> some View {
        ClockWiseCircle().trim(from: 0.0, to: progress).stroke(color, lineWidth: 3)
    }

ClockWiseRoundedRectangle, ClockWiseCircle は、12時位置から描画の始まる RoundedRectangle/Circle です。詳細は、この記事の補足で説明しています。

タイマー管理/進捗管理用クラス(ProgressManager )

押されている時間に応じて(押されている間は時間に応じて)インジケータ表示を伸ばしていくので、押されたことを契機に、タイマーを使った進捗管理が必要となります。

ProgressManager というクラスを作成して管理しています。

基本的に、開始・終了の値を管理したいはずなので、ClosedRange<T> として、値域を保持しています。
現在の進捗値は、current です。

この変化に応じて、ビューを更新したいこともあるはずなので、ObservedObject に準拠して、必要なプロパティに、@Published を付与しています。

今回は、時間に応じてすすんでいくので、進捗管理に使用する型は TimeInterval にしています。
# 実際には、Generics を使って、Comparable な型であれば、データを持てるようにしています。

class ProgressManager<T: Comparable>: ObservableObject {
    @Published private(set) var valueRange: ClosedRange<T>
    @Published private(set) var current: T
    var anyCancellable: AnyCancellable?
    init(_ range: ClosedRange<T>,_ current: T) {
        assert(!range.isEmpty)
        self.valueRange = range
        assert(range.contains(current))
        self.current = current
    }
    
    func setRange(_ range: ClosedRange<T>) {
        self.valueRange = range
        self.current = range.lowerBound
    }
    
    func clearProgress() {
        self.current = valueRange.lowerBound
    }
    
    func setProgress(_ current: T ) {
        //assert(valueRange.contains(current))
        if !valueRange.contains(current) {
            self.current = (current = valueRange.lowerBound) ? valueRange.lowerBound : valueRange.upperBound
        } else {
            self.current = current
        }
    }

    func startWithTimer(updateInterval: TimeInterval, update: @escaping ((Date) -> Void)) {
        self.anyCancellable = Timer.TimerPublisher(interval: updateInterval, runLoop: .main, mode: .common)
            .autoconnect()
            .sink(receiveValue: { newDate in
                update(newDate)
            })
    }
    func clearTimer() {
        anyCancellable?.cancel()
        anyCancellable = nil
        clearProgress()
    }
    func isDone() -> Bool {
        return current == valueRange.upperBound
    }
}

extension ProgressManager where T: AdditiveArithmetic {
    func addProgress(_ progress: T) {
        self.current += progress
        if !valueRange.contains(current) {
            self.current = (current = valueRange.lowerBound) ? valueRange.lowerBound : valueRange.upperBound
        } else {
            self.current = current
        }
    }
}

extension ProgressManager where T: FloatingPoint { // T should be Double (or Float) otherwies we can NOT use division
    var unifiedRatio: T {
        let ratio = (current - valueRange.lowerBound) / (valueRange.upperBound - valueRange.lowerBound)
        return ratio
    }
}
MEMO
タイマーで進捗を更新していく関係で、0% ~ 100% におさまらないことが考えられます。
そのため、進捗更新時に、値域をはみ出しているときは、値域に寄せるようにしています。

ButtonStyle実装

以下が、実装したコードです。

作ってみると、シンプルなコードでできました。

以下、処理概要です。
1) .onLongPressGesture で 長押しのチェックが始まったら、タイマーを起動し、一定の頻度でチェックする。
2) 特定時間が経過するまえに 離された時には、タイマー等をクリアします。この時に、withAnimation を使って、値を戻しているので、インジケータが戻るアニメーションになります。
3) 特定時間経過した時にも押されている時には、アニメーションを終了させて、action を実行します。
4) 注記1: onLongPressGesture の minimumDuration には、特定時間以上の値を与えないと、途中で onLongPressGesture が処理を終わらせてしまうので、与える値には注意が必要。
5) 注記2: action の実行は、timer 処理による定期処理内で行っているので、perform: では行う必要がない

struct LongPressButtonStyle<Indicator: View>: PrimitiveButtonStyle {
    typealias ProgressIndicatorGenerator = ((CGFloat, Color) -> Indicator)

    let pressDuration: TimeInterval
    let color: Color
    let progressIndicatorGenerator: ProgressIndicatorGenerator

    @StateObject private var progressManager: ProgressManager<TimeInterval>

    init(_ pressDuration: TimeInterval,_ color: Color = Color.red,
         progressIndicatorGenerator: @escaping ProgressIndicatorGenerator) {
        self.pressDuration = pressDuration
        self.color = color
        self.progressIndicatorGenerator = progressIndicatorGenerator
        self._progressManager = StateObject(wrappedValue: ProgressManager(0...pressDuration, 0.0)) // dummy values
    }

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .overlay {
                progressIndicatorGenerator(progressManager.unifiedRatio, color)
            }
            .onLongPressGesture(minimumDuration: pressDuration + 2.5, perform: {
            }, onPressingChanged: { newValue in
                if !newValue {
                    // button released
                    withAnimation {
                        progressManager.clearTimer()
                    }
                } else {
                    let now = Date().timeIntervalSinceReferenceDate
                    progressManager.setRange(now...(now+pressDuration))
                    progressManager.startWithTimer(updateInterval: max(pressDuration / 120, 0.001),
                                                   update: { newDate in
                        progressManager.setProgress(newDate.timeIntervalSinceReferenceDate)
                        if progressManager.isDone() {
                            configuration.trigger()
                            progressManager.clearTimer()
                        }
                    })
                }
            })
    }
}

サンプルアプリ実装

作成した LongPressButtonStyle の動作は以下のようになります。

サンプルコードは、以下です。

struct ContentView: View {
    @State private var circlePushed = false
    @State private var rectanglePushed = false
    //@State private var state = "Pushed"
    var body: some View {
        HStack {
            Button(action: {
                circlePushed = true
                DispatchQueue.main.asyncAfter(deadline: .now()+3, execute: {
                    circlePushed = false
                })
            }, label: {
                Image(systemName: "multiply.circle").resizable().scaledToFit()
                    .frame(width: 80, height: 80)
                    .background(Circle().fill(circlePushed ? Color.red : Color.clear))
            })
            .buttonStyle(LongPressButtonStyle(2, // Color.blue,
                                              progressIndicatorGenerator: circleProgressIndicator))
            Button(action: {
                rectanglePushed = true
                DispatchQueue.main.asyncAfter(deadline: .now()+3, execute: {
                    rectanglePushed = false
                })
            }, label: {
                Text("Button")
                    .frame(width: 80, height: 80)
                    .background(RoundedRectangle(cornerRadius: 6).fill(rectanglePushed ? Color.blue : Color.clear))
            })
            .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.blue, lineWidth: 1))
            .buttonStyle(LongPressButtonStyle(2, Color.blue,
                                              progressIndicatorGenerator: roundedRectangleProgressIndicator))
        }
        .padding()
    }
    
    func defaultProgressIndicator(_ progress: CGFloat,_ color: Color) -> some View {
        ContainerRelativeShape().trim(from: 0.0, to: progress).stroke(color, lineWidth: 3)
    }
    
    func roundedRectangleProgressIndicator(_ progress: CGFloat,_ color: Color) -> some View {
        ClockWiseRoundedRectangle(cornerRadius: 6).trim(from: 0.0, to: progress).stroke(color, lineWidth: 3)
    }
    
    func circleProgressIndicator(_ progress: CGFloat,_ color: Color) -> some View {
        ClockWiseCircle().trim(from: 0.0, to: progress).stroke(color, lineWidth: 3)
    }
}

補足

既存の Shape でも、.trim を使って簡易的なアニメーションは簡単に作れるのですが、なんとなく12時位置から始まるアニメーションにしたかったので、自前の Shape を作りました。

アニメーションに使用する Shape

SwiftUI の Shape を trim を使ってアニメーションさせてみるとわかりますが、図形ごとに、描画開始位置が異なります。
RoundedRectangle や Rectangle は、左上から 時計回りに描画が行われます。
Circle は、3時の位置から、時計回りに描画が開始されます。

今回作りたいアニメーションは、ボタンの上部、時計で言うと12時の箇所から時計回りにぐるっと書かれていくアニメーションです。(どこから始まっても機能的に違いはありませんが、今回は12時開始でつくってみたかったのです・・・)

そこで、Shape をベースにした ClockWiseCircle, ClockWiseRoundedRectangle を作りました。
いずれも、12時の位置から開始して 時計回りに描画する Shape にしています。

struct ClockWiseRoundedRectangle: Shape {
    let cornerRadius: CGFloat
    init(cornerRadius: CGFloat) {
        self.cornerRadius = cornerRadius
    }
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.move(to: CGPoint(x: rect.midX, y: rect.minY))
            path.addLine(to: CGPoint(x:rect.maxX - cornerRadius, y: rect.minY))
            path.addArc(center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius),
                        radius: cornerRadius, startAngle: Angle.degrees(-90), endAngle: Angle.degrees(0), clockwise: false)
            path.addLine(to: CGPoint(x:rect.maxX, y: rect.maxY - cornerRadius))
            path.addArc(center: CGPoint(x: rect.maxX - cornerRadius, y: rect.maxY - cornerRadius),
                        radius: cornerRadius, startAngle: Angle.degrees(0), endAngle: Angle.degrees(90), clockwise: false)

            path.addLine(to: CGPoint(x:rect.minX + cornerRadius, y: rect.maxY))
            path.addArc(center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius),
                        radius: cornerRadius, startAngle: Angle.degrees(90), endAngle: Angle.degrees(180), clockwise: false)

            path.addLine(to: CGPoint(x:rect.minX, y: rect.minY + cornerRadius))
            path.addArc(center: CGPoint(x: rect.minX + cornerRadius, y: rect.minY + cornerRadius),
                        radius: cornerRadius, startAngle: Angle.degrees(180), endAngle: Angle.degrees(270), clockwise: false)
            path.addLine(to: CGPoint(x:rect.midX, y: rect.minY))
        }
    }
}

struct ClockWiseCircle: Shape {
    func path(in rect: CGRect) -> Path {
        Path { path in
            path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
                        radius: min(rect.width / 2.0, rect.height / 2.0),
                        startAngle: Angle.degrees(-90), endAngle: Angle.degrees(270), clockwise: false)
        }
    }
}
MEMO
Circle のような点対称の図形で あれば、.rotationEffect や .rotation3DEffect を使用して 描画を回転させるという方法もあります。
RoundedRectangle については、上記のように描画開始位置等を調整するしか方法がありません。

まとめ

インジケータ付き、LongPress ボタン を作ってみました。

実際に 動作を変更する ButtonStyle を作ったことで、PrimitiveButtonStyle/ButtonStyle をより理解できた気がします。

面白い(?) ButtonStyle ができたら、ぜひ教えてください。

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMastery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle"という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

コメントを残す

メールアドレスが公開されることはありません。