はじめに
本記事はSwiftUIにおけるパスワード入力フォームの作り方についての記事です。
要件
以下作成するパスワード入力フォームの要件になります。
- マスクありなしの入力欄の切り替えが可能な事
- 上記切り替え時にキーボードを常に表示させる事
実装方法
SecureFieldとTextFieldを組み合わせて実装します。
以下具体的なコードの内容になります。
struct PasswordFormView: View {
@State var text: String = ""
@State var isMasked: Bool = false
@FocusState var isTextFieldFocused: Bool
@FocusState var isSecureFieldFocused: Bool
var body: some View {
VStack(spacing: 0) {
ZStack {
HStack(spacing: 0) {
TextField("マスクなし", text: $text)
.focused($isTextFieldFocused)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
Image(systemName: "eye")
.onTapGesture {
isMasked = true
if isTextFieldFocused {
isSecureFieldFocused = true
}
}
}
.opacity(isMasked ? 0 : 1)
HStack(spacing: 0) {
SecureField("マスクあり", text: $text)
.focused($isSecureFieldFocused)
Image(systemName: "eye.slash")
.onTapGesture {
isMasked = false
if isSecureFieldFocused {
isTextFieldFocused = true
}
}
}
.opacity(isMasked ? 1 : 0)
}
Rectangle()
.frame(height: 1)
.padding(.top, 10)
.foregroundColor(Color.black)
}
.padding(.horizontal, 60)
}
}
各要件の対応方法については下記の通りです。
マスクありなしの入力欄の切り替えが可能な事
opacityモディファイアでTextFieldとSecureFieldの表示状態を制御する事で、
マスクありなしの入力フォームの切り替えを行なっています。
上記切り替え時にキーボードを常に表示させる事
表示状態の切り替えのタイミングで入力フォームのフォーカスを制御する事で、
常にキーボードを表示させています。
セキュリティ面
SecureFieldには下記の様なセキュリティ対策がデフォルトで行われています。
- 実機でスクリーンショットするとドットが非表示になる
- フィールドの内容を切り取ったりコピーできない様になる
- サードパーティーキーボードのブロック
- CapsLockが有効になっている時にインジケータを表示する
これによりマスクありの入力フォームの安全性を高めてくれます。
問題点
2つの要件を満たしセキュリティ対策も行えましたが、下記の様な問題点が存在します。
- SecureFieldはフォーカスの前後で入力内容を引き継げない
この問題はTextFieldからSecureFieldに入力フォームを切り替えた際に発生します。
具体的な発生手順は下記の通りです。
発生手順
- TextFieldで”abc”と文字入力
- 画像アイコンをタップしてSecureFieldに表示の切り替え
- SecureFieldで”d”を入力
- SecureFieldで”abcd”ではなく”d”を表示
ここからはこの問題の解消方法を3つ紹介します。
1. onChangeモディファイアを使用する方法
概要
前述したTextFieldとSecureField組み合わせた実装に
.onChangeモディファイアを加えてSecureFieldの入力内容を制御する方法です。
実装方法
struct PasswordFormView: View {
@State var text: String = ""
@State var isMasked: Bool = false
@State var shouldKeepPassword = false
@FocusState var isTextFieldFocused: Bool
@FocusState var isSecureFieldFocused: Bool
var body: some View {
VStack(spacing: 0) {
ZStack {
HStack(spacing: 0) {
TextField("マスクなし", text: $text)
.focused($isTextFieldFocused)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
Image(systemName: "eye")
.onTapGesture {
isMasked = true
if isTextFieldFocused {
isSecureFieldFocused = true
shouldKeepPassword = true
}
}
}
.opacity(isMasked ? 0 : 1)
HStack(spacing: 0) {
SecureField("マスクあり", text: $text)
.focused($isSecureFieldFocused)
Image(systemName: "eye.slash")
.onTapGesture {
isMasked = false
if isSecureFieldFocused {
isTextFieldFocused = true
}
}
}
.opacity(isMasked ? 1 : 0)
}
Rectangle()
.frame(height: 1)
.padding(.top, 10)
.foregroundColor(Color.black)
}
.padding(.horizontal, 60)
.onChange(of: text) { oldValue, newValue in
if newValue.count == 1 && shouldKeepPassword {
text = oldValue + newValue
shouldKeepPassword = false
}
}
}
}
TextFieldからSecureFieldの切り替え時にonChangeモディファイアで、
古い入力内容と新しい入力内容を繋げ入力フォームへ再代入しています。
これにより入力内容を引き継いでいます。
問題点
当初の入力内容が引き継げないという問題は解消されましたが、
新たに下記の問題が発生します。
- SecureField切り替え時全選択を行い入力すると上書きできなくなる
これに関してはSecureFieldは現状全選択を検知する方法を提供していないので、
どうする事もできません。
結論
当初の問題点を解消しようとした結果、解消できない問題が発生します。
また当初の問題を解消する方法も、SecureFieldの仕様を捻じ曲げるような方法である為、
今後SecureFieldの仕様に変更が入った場合不具合の温床になる可能性があります。
2. TextFieldを2つ使用する方法
概要
SecureFieldの使用を避け、
マスクありなしフォームどちらでもTextFieldを使用する方法です。
実装方法
状態管理側:
class PasswordStore: ObservableObject {
@Published var password: String = ""
var maskedPassword: String {
get {
return String(repeating: "●", count: password.count)
}
set {
// 入力値が削除された場合
if password.count > newValue.count {
// 全選択を行った後の入力で新しい入力値が1になる場合
if newValue.count == 1 {
password = newValue
// 通常の一文字ずつ削除する場合
} else {
password.removeLast()
}
// 新しく入力値が追加された場合
} else if password.count < newValue.count {
// 新しく入力された値の最後のみ取り出して、状態管理passwordに追加
if let validPasswordValue = newValue.last {
let validPasswordString: String = String(validPasswordValue)
password.append(validPasswordString)
}
}
}
}
}
UI側:
struct PasswordFormView: View {
@ObservedObject var store: PasswordStore = PasswordStore()
@State var isMasked: Bool = false
@FocusState var isTextFieldFocused: Bool
@FocusState var isSecureFieldFocused: Bool
var body: some View {
VStack(spacing: 0) {
ZStack {
HStack(spacing: 0) {
TextField("マスクなし", text: $store.password)
.focused($isTextFieldFocused)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
Image(systemName: "eye")
.onTapGesture {
isMasked = true
if isTextFieldFocused {
isSecureFieldFocused = true
}
}
}
.opacity(isMasked ? 0 : 1)
HStack(spacing: 0) {
SecureField("マスクあり", text: $store.maskedPassword)
.focused($isSecureFieldFocused)
Image(systemName: "eye.slash")
.onTapGesture {
isMasked = false
if isSecureFieldFocused {
isTextFieldFocused = true
}
}
}
.opacity(isMasked ? 1 : 0)
}
Rectangle()
.frame(height: 1)
.padding(.top, 10)
.foregroundColor(Color.black)
}
.padding(.horizontal, 60)
}
}
PasswordStoreを経由してマスクありなしの入力情報を2つのTextFieldに与えています。
TextFieldはフォーカス状態によらず入力情報は引き継がれる為、2つのTextFieldを切り替えても入力内容は失われません。
結論
入力内容が引き継がれない当初の問題はSecureFieldを使わない事で解消されました。
一方でSecureFieldが担保していたセキュリティ対策が全て無効になる為、セキュリティ面で不安が残る状態です。
3. UITextFieldを使用する方法
概要
マスクなし入力フォームでSwfitUIのTextFieldをマスクあり入力フォームでUIKitのUITextFieldをそれぞれ使用する方法です。
実装方法
UITextField側:
struct CustomTextField: UIViewRepresentable {
@Binding var text: String
func makeCoordinator() -> SampleCorrdinator {
SampleCorrdinator(self)
}
func makeUIView(context: Context) -> UITextField {
let textField = SecureTextField()
textField.delegate = context.coordinator
textField.isSecureTextEntry = true
textField.clearsOnBeginEditing = false
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
if uiView.text != text {
uiView.text = text
}
}
}
class SecureTextField: UITextField {
override func becomeFirstResponder() -> Bool {
let success = super.becomeFirstResponder()
if isSecureTextEntry, let text = self.text {
self.text?.removeAll()
insertText(text)
}
return success
}
}
class SampleCorrdinator: NSObject, UITextFieldDelegate {
var parent: CustomTextField
init(_ parent: CustomTextField) {
self.parent = parent
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textFieldDidChangeSelection(_ textField: UITextField) {
DispatchQueue.main.async {
self.parent.text = textField.text ?? ""
}
}
}
PasswordFormView側:
struct PasswordFormView: View {
@State var text: String = ""
@State var isMasked: Bool = false
@FocusState var isTextFieldFocused: Bool
var body: some View {
VStack(spacing: 0) {
ZStack {
HStack(spacing: 0) {
TextField("マスクなし", text: $text)
.focused($isTextFieldFocused)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.frame(height: 40)
Image(systemName: "eye")
.onTapGesture {
isMasked = true
}
}
.opacity(isMasked ? 0 : 1)
HStack(spacing: 0) {
CustomTextField(text: $text)
.frame(height: 40)
Image(systemName: "eye.slash")
.onTapGesture {
isMasked = false
isTextFieldFocused = true
}
}
.opacity(isMasked ? 1 : 0)
}
Rectangle()
.frame(height: 1)
.padding(.top, 10)
.foregroundColor(Color.black)
}
.padding(.horizontal, 60)
}
}
CustomTextFieldでUITextFieldを実装して、
PasswordFormView側でTextFieldと使い分けています。
UITextFieldはisSecureTextEntryを有効化する事でマスクされた入力フォームに変更できます。
UITextFieldはSecureField同様に入力内容を引き継げないですがbecomeFirstResponderでフォーカス時に入力内容を再代入する事で、あたかも入力内容が引き継がれているかの様にしています。
結論
UITextFieldを使用する事で入力内容が引き継がれない問題は解消されました。
またisSecureTextEntryによって下記の様なセキュリティ対策も行えます。
(コピーの無効化、スクリーンショット無効、画面録画や共有無効等)
一方でbecomeFirstResponderの仕様に変更が入った場合に不具合が発生する可能性があります。
全体のまとめ
- マスクありなしの入力欄の切り替えが可能な事
- 上記切り替え時にキーボードを常に表示させる事
満たすべき要件が上記2つのみなら、
最初のTextFieldとSecureFieldの組み合わせで問題ありません。
これが最も一般的な実装方法になります。
一方でマスクありなしの入力フォーム間で入力内容を引き継がせたい場合は、
現状2又は3の方法を取る必要があります。
セキュリティ面を考慮せず単にドットで、
文字を塗り潰すだけで良い場合は2が最もシンプルです。
セキュリティ面も考慮しなくてはいけない場合、
不具合の発生リスクはありますが3を選択する必要があります。
現状マスクありなしの入力フォーム間で入力内容を引き継がせられる安全な方法は存在せず、ワークアラウンドな実装をする必要があります。
※もし他の方法をご存知の方いらっしゃいましたらコメントで教えていただけると大変助かります!!!
参考
https://zenn.dev/alc_tecdev/articles/kame_ga_13_hiki
https://developer.apple.com/documentation/SwiftUI/SecureField
https://developer.apple.com/jp/design/human-interface-guidelines/entering-data
https://stackoverflow.com/questions/7305538/uitextfield-with-secure-entry-always-getting-cleared-before-editing
https://developer.apple.com/documentation/uikit/uitextinputtraits/issecuretextentry