TogglableSecureInput.swift 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. // LoopFollow
  2. // TogglableSecureInput.swift
  3. import SwiftUI
  4. struct TogglableSecureInput: View {
  5. enum Style { case singleLine, multiLine }
  6. let placeholder: String
  7. @Binding var text: String
  8. let style: Style
  9. var textContentType: UITextContentType? = nil
  10. @State private var isVisible = false
  11. @FocusState private var isFocused: Bool
  12. @FocusState private var isMultilineFocused: Bool
  13. var body: some View {
  14. HStack(alignment: .top) {
  15. Group {
  16. switch style {
  17. case .singleLine:
  18. ZStack(alignment: .trailing) {
  19. if isVisible {
  20. TextField(placeholder, text: $text)
  21. .multilineTextAlignment(.trailing)
  22. .textContentType(textContentType)
  23. .submitLabel(.done)
  24. .focused($isFocused)
  25. } else {
  26. // A real (masked) SecureField, not static text, so the
  27. // value stays editable while hidden and — crucially —
  28. // iOS AutoFill/keychain has a secure field to fill.
  29. SecureField(placeholder, text: $text)
  30. .multilineTextAlignment(.trailing)
  31. .textContentType(textContentType)
  32. .submitLabel(.done)
  33. .focused($isFocused)
  34. }
  35. }
  36. case .multiLine:
  37. ZStack(alignment: .topLeading) {
  38. TextEditor(text: $text)
  39. .focused($isMultilineFocused)
  40. .frame(minHeight: 100)
  41. .opacity(isVisible ? 1 : 0)
  42. .disabled(!isVisible)
  43. .toolbar {
  44. ToolbarItemGroup(placement: .keyboard) {
  45. if isMultilineFocused {
  46. Spacer()
  47. Button("Done") {
  48. isMultilineFocused = false
  49. }
  50. }
  51. }
  52. }
  53. if !isVisible {
  54. HStack {
  55. Spacer()
  56. Text(maskString)
  57. .font(.body.monospaced())
  58. .foregroundColor(.primary)
  59. .allowsHitTesting(false)
  60. }
  61. }
  62. }
  63. .frame(minHeight: 100)
  64. }
  65. }
  66. .textInputAutocapitalization(.never)
  67. .autocorrectionDisabled()
  68. .privacySensitive()
  69. Button { isVisible.toggle() } label: {
  70. Image(systemName: isVisible ? "eye.slash" : "eye")
  71. .foregroundColor(.secondary)
  72. }
  73. .buttonStyle(.plain)
  74. .padding(.leading, 4)
  75. }
  76. .contentShape(Rectangle())
  77. .onTapGesture {
  78. switch style {
  79. case .singleLine:
  80. // The hidden state is already an editable SecureField, so tapping
  81. // just focuses it — no need to reveal the plaintext to type.
  82. isFocused = true
  83. case .multiLine:
  84. // The multi-line editor is only editable once revealed.
  85. if !isVisible { isVisible = true }
  86. isMultilineFocused = true
  87. }
  88. }
  89. }
  90. private var maskString: String {
  91. text.map { $0.isNewline ? "\n" : "•" }.joined()
  92. }
  93. }