【SwiftUI】Floating Buttonの実装をしてみたのでメモ(TextFieldの入力チェック付き)

SwiftUIでFloating Buttonを趣味アプリで実装してみたので、やり方メモを公開します
2020.08.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

概要

大阪オフィスの山田です。今回、SwiftUIでFloating Buttonを趣味アプリで実装してみたので、やり方メモを公開します。

環境

  • Xcode: 11.5
  • macOS: 10.15.4

Floating Buttonを作る

このようなボタンを実装します。

以下の実装でFloating Buttonの外観を作ります。

struct FloatingButton: View {
    var body: some View {
        VStack {  // --- 1
            Spacer()
            HStack { // --- 2
                Spacer()
                Button(action: {
                    print("Tapped!!") // --- 3
                }, label: {
                    Image(systemName: "pencil")
                        .foregroundColor(.white)
                        .font(.system(size: 24)) // --- 4
                })
                    .frame(width: 60, height: 60)
                    .background(Color.orange)
                    .cornerRadius(30.0)
                    .shadow(color: .gray, radius: 3, x: 3, y: 3)
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: 16.0, trailing: 16.0)) // --- 5

            }
        }
    }
}

プログラムを解説します。

  • 1, 2: ボタンが右下に配置されるよう、VStackHStackSpacerを使用
  • 3: ボタンをタップした時のアクション
    • 現時点ではまだ、文字を出力するだけの処理。ここは後ほど実装します。
  • 4: ボタンのラベル部分を作成
    • 今回はシステムで用意されている画像を使って、色を白色にしています。そのままでは少し小さいのでfontメソッドを使って、大きくしています。
  • 5: ボタン全体のデザイン
    • 大きさは固定値指定、cornerRadiusも固定値指定にして丸くしています。backgroundで背景色を設定しています。paddingで、ボタンの下と右にスペースを確保しています。このメソッドを省くと、画面右下にぴったりくっつきます。そして、最後にshadowで影をつけています。

これでFloating Buttonの外観ができたので、実際に、浮いたボタンになっているか確認します。

Floating Buttonをパーツとして表示する

実際にFloating Buttonを浮いたように表示するにはZStackを使います。これを使うことでViewを重ね合わせたような外観を実装することができます。

struct InputView: View {
    var body: some View {
        ZStack {
            VStack {
                Spacer()
                HStack() {
                    Spacer()
                    Text("Yattane!")
                        .font(.title)
                }
            }
            FloatingButton()
        }
    }
}

実装したInputViewを表示すると、以下の画像のようになります。Textの上にボタンが被さっているのがわかります。

TextFieldでキーボードが表示された時に隠れないようにする

この実装をするには、キーボードが表示された時にキーボードの高さを取得、通知するクラスが必要です。SwiftUIでキーボードで文字が隠れないように処理をいれる: Qiitaや、KeyboardObserving: GitHubの実装が参考になります。Notification Centerを使ってキーボードの高さを取得します。今回はキーボード領域が被った分だけずらす、のような複雑なことはせず、単にキーボードが表示されたら高さを、非表示になれば0を設定するようにしています。

class KeyboardService: ObservableObject {
    /// キーボードの高さを伝えるプロパティ
    @Published var keyboardHeight: CGFloat = 0.0
    let defaultNotification = NotificationCenter.default

    /// キーボードの監視開始
    func start() {
        defaultNotification.addObserver(
            self,
            selector: #selector(self.keyboardWillShow(_:)),
            name: UIResponder.keyboardWillShowNotification,
            object: nil)
        defaultNotification.addObserver(
            self,
            selector: #selector(self.keyboardWillHide(_:)),
            name: UIResponder.keyboardWillHideNotification,
            object: nil)
    }

    /// キーボードの監視終了
    func end() {
        NotificationCenter
            .default
            .removeObserver(
                self,
                name: UIResponder.keyboardWillShowNotification,
                object: nil)
        NotificationCenter
            .default
            .removeObserver(
                self,
                name: UIResponder.keyboardWillHideNotification,
                object: nil)
    }

    /// キーボードが表示されたら、高さをkeyboardHeighに設定する
    @objc func keyboardWillShow(_ notification: Notification) {
        guard let rect = (notification
            .userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
        self.keyboardHeight = rect.size.height
    }

    /// キーボードが表示されたら、高さを0に設定する
    @objc func keyboardWillHide(_ notification: Notification) {
        self.keyboardHeight = 0
    }
}

これで、キーボードの高さを取得するクラスが実装できました。次にViewに繋ぎこみます。

struct InputView: View {
    @State var inputText = ""
    @ObservedObject var keyboard = KeyboardService() // --- 1

    var body: some View {
        ZStack {
            VStack {
                TextField(
                    "入力してね",
                    text: $inputText,
                    onEditingChanged: { editing in
                }).padding() // --- 5
            }
            FloatingButton(bottomPadding: self.keyboard.keyboardHeight) // --- 2
        }.onAppear {
            self.keyboard.start() // --- 3
        }.onDisappear {
            self.keyboard.end() // --- 4
        }
    }
}
  • 1: ObservedObjectとしてKeyboardServiceを宣言
  • 2: KeyboardServiceを使ってFloatingButtonにキーボードの高さを伝える
    • FloatingButtonクラスは後ほど修正します。
  • 3, 4: Appear時、DisAppear時にそれぞれ、キーボードの監視を開始、終了
  • 5: 入力用のTextFieldを用意

続いて、FloatingButtonの実装に進みます。

struct FloatingButton: View {
    var bottomPadding: CGFloat = 0 // --- 1
    var body: some View {
        VStack {
            Spacer()
            HStack {
                Spacer()
                Button(action: {
                    print("Tapped!!")
                }, label: {
                    Image(systemName: "pencil")
                        .foregroundColor(.white)
                        .font(.system(size: 24))
                })
                    .frame(width: 60, height: 60)
                    .background(Color.orange)
                    .cornerRadius(30.0)
                    .shadow(color: .gray, radius: 3, x: 3, y: 3)
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: bottomPadding + 16.0, trailing: 16.0)) // --- 2
                    .animation(.easeOut)
            }
        }
    }
}
  • 1: ボタン下側のpaddingを保持する変数を宣言
  • 2: paddingの適用

キーボードが表示された時、高さが伝わり、paddingの値を変更する、という仕組みになっています。

実際に動かすと以下のようになります。

ボタンタップ時の動きを生成時に指定する

ボタンタップ時に入力された文字を画面に表示するようにします。それでは実装していきます。まず、ボタンタップで入力された画面を表示します。

struct InputView: View {
    @State var inputText = ""
    @State var outputText = "" // --- 1
    @ObservedObject var keyboard = KeyboardService()

    var body: some View {
        ZStack {
            VStack {
                Text(outputText) // --- 2
                TextField(
                    "入力してね",
                    text: $inputText,
                    onEditingChanged: { editing in
                }).padding()
            }
            FloatingButton(
                bottomPadding: self.keyboard.keyboardHeight,
                tappedHandler: self.showText // --- 3
            )
        }.onAppear {
            self.keyboard.start()
        }.onDisappear {
            self.keyboard.end()
        }
    }

    // --- 4
    func showText() {
        outputText = inputText
    }
}

struct FloatingButton: View {
    var bottomPadding: CGFloat = 0
    var tappedHandler: (() -> Void)? = nil // --- 5
    var body: some View {
        VStack {
            Spacer()
            HStack {
                Spacer()
                Button(action: {
                    self.tappedHandler?() // --- 6
                }, label: {
                    Image(systemName: "pencil")
                        .foregroundColor(.white)
                        .font(.system(size: 24))
                })
                    .frame(width: 60, height: 60)
                    .background(Color.orange)
                    .cornerRadius(30.0)
                    .shadow(color: .gray, radius: 3, x: 3, y: 3)
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: bottomPadding + 16.0, trailing: 16.0))
                    .animation(.easeOut)
            }
        }
    }
}

まず、InputViewの修正です。

  • 1: 表示する文字列を格納する変数を用意
  • 2: 変数outputTextをTextで表示
  • 3: ボタンをタップした時に動作させる内容を指定
    • ここでは4にてメソッドを定義して、それを呼び出すようにしています。

続いてFloatingButtonです。

  • 5: タップ時に動作させるハンドラーを変数として定義
  • 6: タップ時に呼び出すようにしています。

実際の動きはこちらになります。

TextFieldの入力状況でdisabledを制御する

次は、1文字も入力されていない時には、ボタンを押せないようにします。

struct InputView: View {
    @State var inputText = ""
    @State var outputText = ""
    @ObservedObject var keyboard = KeyboardService()

    var body: some View {
        ZStack {
            VStack {
                Text(outputText)
                TextField(
                    "入力してね",
                    text: $inputText,
                    onEditingChanged: { editing in
                }).padding()
            }
            FloatingButton(
                bottomPadding: self.keyboard.keyboardHeight,
                tappedHandler: self.showText,
                isDisabled: inputText.isEmpty // --- 1
            )
        }.onAppear {
            self.keyboard.start()
        }.onDisappear {
            self.keyboard.end()
        }
    }

    func showText() {
        outputText = inputText
    }
}

struct FloatingButton: View {
    var bottomPadding: CGFloat = 0
    var tappedHandler: (() -> Void)? = nil
    var isDisabled: Bool = false // --- 2
    var body: some View {
        VStack {
            Spacer()
            HStack {
                Spacer()
                Button(action: {
                    self.tappedHandler?()
                }, label: {
                    Image(systemName: "pencil")
                        .foregroundColor(.white)
                        .font(.system(size: 24))
                })
                    .frame(width: 60, height: 60)
                    .background(backgroundColor) // --- 3
                    .cornerRadius(30.0)
                    .shadow(color: .gray, radius: 3, x: 3, y: 3)
                    .padding(EdgeInsets(top: 0, leading: 0, bottom: bottomPadding + 16.0, trailing: 16.0))
                    .animation(.easeOut)
                    .disabled(isDisabled) // --- 4
            }
        }
    }

    // --- 5
    var backgroundColor: Color {
        return isDisabled ? Color.gray : Color.orange
    }
}
  • 1, 2: inputTextが空だった場合は、isDisabled変数をtrueにする。そうでなければfalse
  • 4: 変数isDisabledの値でdisableにするか否かを決定
  • 3, 5: disableの時はグレーアウトするように背景色を変更

実際の動きはこちらになります。

おわりに

SwiftUIおもしろー。OSSになったら良いのになぁ。

参考