Skip to content

Commit

Permalink
Merge pull request #46 from kiwicom/inputField-add-secureField
Browse files Browse the repository at this point in the history
Implement Secured InputField component
  • Loading branch information
PavelHolec authored Mar 23, 2022
2 parents caa5550 + 818b962 commit 2549ae9
Show file tree
Hide file tree
Showing 3 changed files with 221 additions and 18 deletions.
102 changes: 84 additions & 18 deletions Sources/Orbit/Components/InputField.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import UIKit

/// Also known as textbox. Offers users a simple input for a form.
///
Expand All @@ -15,6 +16,7 @@ public struct InputField: View {
@Binding private var value: String
@Binding private var messageHeight: CGFloat
@State private var isEditing: Bool = false
@State private var isSecureTextEntry: Bool = true

let label: String
let placeholder: String
Expand All @@ -25,16 +27,16 @@ public struct InputField: View {
let keyboard: UIKeyboardType
let autocapitalization: UITextAutocapitalizationType
let isAutocompleteEnabled: Bool
let isSecure: Bool
let message: MessageType

let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void
let suffixAction: () -> Void

public var body: some View {
VStack(alignment: .leading, spacing: .xxSmall) {
FormFieldLabel(label)

InputContent(
prefix: prefix,
suffix: suffix,
Expand All @@ -44,9 +46,22 @@ public struct InputField: View {
suffixAction: suffixAction
) {
HStack(spacing: 0) {
textField
Spacer(minLength: 0)
clearButton
input
.textFieldStyle(TextFieldStyle(leadingPadding: 0))
.autocapitalization(autocapitalization)
.disableAutocorrection(isAutocompleteEnabled == false)
.textContentType(textContent)
.keyboardType(keyboard)
.font(.orbit(size: Text.Size.normal.value, weight: .regular))
.accentColor(.blueNormal)
.frame(height: Layout.preferredButtonHeight)
.background(textFieldPlaceholder, alignment: .leading)
.disabled(state == .disabled)
if isSecure {
securedSuffix
} else {
clearButton
}
}
}

Expand All @@ -58,6 +73,31 @@ public struct InputField: View {
}
}

@ViewBuilder var input: some View {
if isSecure {
secureField
} else {
textField
}
}

@ViewBuilder var secureField: some View {
SecureTextField(
text: $value,
isSecured: $isSecureTextEntry,
isEditing: $isEditing,
style: .init(
textContentType: textContent,
keyboardType: keyboard,
font: .orbit(size: Text.Size.normal.value, weight: .regular),
state: state
),
onEditingChanged: onEditingChanged,
onCommit: onCommit
)
.background(textFieldPlaceholder, alignment: .leading)
}

@ViewBuilder var textField: some View {
TextField(
"",
Expand All @@ -68,16 +108,6 @@ public struct InputField: View {
},
onCommit: onCommit
)
.textFieldStyle(TextFieldStyle(leadingPadding: 0))
.autocapitalization(autocapitalization)
.disableAutocorrection(isAutocompleteEnabled == false)
.textContentType(textContent)
.keyboardType(keyboard)
.font(.orbit(size: Text.Size.normal.value, weight: .regular))
.accentColor(.blueNormal)
.frame(height: Layout.preferredButtonHeight)
.background(textFieldPlaceholder, alignment: .leading)
.disabled(state == .disabled)
}

@ViewBuilder var textFieldPlaceholder: some View {
Expand All @@ -98,8 +128,20 @@ public struct InputField: View {
}
}
}

@ViewBuilder var securedSuffix: some View {
if value.isEmpty == false, state != .disabled {
Icon(isSecureTextEntry ? .visibility : .visibilityOff, size: .normal)
.padding(.horizontal, .small)
.contentShape(Rectangle())
.onTapGesture {
isSecureTextEntry.toggle()
}
}
}
}


// MARK: - Inits
public extension InputField {

Expand All @@ -119,6 +161,7 @@ public extension InputField {
keyboard: UIKeyboardType = .default,
autocapitalization: UITextAutocapitalizationType = .none,
isAutocompleteEnabled: Bool = false,
isSecure: Bool = false,
message: MessageType = .none,
messageHeight: Binding<CGFloat> = .constant(0),
onEditingChanged: @escaping (Bool) -> Void = { _ in },
Expand All @@ -137,6 +180,7 @@ public extension InputField {
self.keyboard = keyboard
self.autocapitalization = autocapitalization
self.isAutocompleteEnabled = isAutocompleteEnabled
self.isSecure = isSecure
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.suffixAction = suffixAction
Expand Down Expand Up @@ -186,13 +230,13 @@ struct InputFieldPreviews: PreviewProvider {
InputField("Default", value: .constant("InputField Value"))
InputField("Modified", value: .constant("Modified value"), state: .modified)
InputField("Focused", value: .constant("Focus / Help"), message: .help("Help message"))
InputField("Secured", value: .constant("password"), isSecure: true)
InputField(
"InputField with a long multiline label to test that it works",
value: .constant("Error value with a very long length to test that it works"),
message: .error("Error message, also very long and multi-line to test that it works.")
)
InputField(value: .constant("InputField with no label"))
standalone
InputField(value: .constant("InputField with CountryFlag prefix"), prefix: .countryFlag("us"))
}

Expand All @@ -209,8 +253,9 @@ struct InputFieldLivePreviews: PreviewProvider {

static var previews: some View {
PreviewWrapper()
securedWrapper
}

struct PreviewWrapper: View {

@State var message: MessageType = .none
Expand Down Expand Up @@ -258,7 +303,28 @@ struct InputFieldLivePreviews: PreviewProvider {
}
.animation(.easeOut(duration: 0.25), value: message)
.padding()
.previewDisplayName("Run Live Preview")
.previewDisplayName("Run Live Preview with Input Field")
}
}

static var securedWrapper: some View {

PreviewWrapperWithState(initialState: "") { state in

VStack(alignment: .leading, spacing: .medium) {
Heading("Heading", style: .title2)

InputField(
value: state,
suffix: .none,
textContent: .password,
isSecure: true
)
}
.padding()
.previewDisplayName("Run Live Preview with Secured Input Field")

}
}

}
15 changes: 15 additions & 0 deletions Sources/Orbit/Support/Forms/InputStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ public enum InputState {
case .default, .modified: return .inkLighter
}
}

public var textUIColor: UIColor {
switch self {
case .disabled: return .cloudDarkerActive
case .default: return .inkNormal
case .modified: return .blueDark
}
}

public var placeholderUIColor: UIColor {
switch self {
case .disabled: return textUIColor
case .default, .modified: return .inkLighter
}
}
}

/// Content for inputs that share common layout with a prefix and suffix.
Expand Down
122 changes: 122 additions & 0 deletions Sources/Orbit/Support/Views/SecureTextField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import SwiftUI
import UIKit

struct SecureTextFieldStyle {
// FIXME: support autocapitalization to match InputField style
let textContentType: UITextContentType?
let keyboardType: UIKeyboardType
let font: UIFont
let state: InputState
}

struct SecureTextField: UIViewRepresentable {
typealias UIViewType = UITextField

@Binding var text: String
@Binding var isSecured: Bool
@Binding var isEditing: Bool

let style: SecureTextFieldStyle
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void

func makeUIView(context: Context) -> UITextField {
let textFied = UITextField()
textFied.autocorrectionType = .no
textFied.delegate = context.coordinator

textFied.text = text
textFied.isSecureTextEntry = isSecured
textFied.textContentType = style.textContentType
textFied.keyboardType = style.keyboardType
textFied.font = style.font
textFied.textColor = style.state.textUIColor
textFied.clearsOnBeginEditing = false
textFied.isEnabled = style.state != .disabled

if isEditing && textFied.canBecomeFirstResponder {
textFied.becomeFirstResponder()
}

return textFied
}

func updateUIView(_ uiView: UITextField, context: Context) {
uiView.isSecureTextEntry = isSecured

guard uiView.isFirstResponder else {
uiView.text = text
return
}

// Workaround. Without it, UITextField will erase it's own current value on frist input
let didUserJustDidStartEditing = uiView.isSecureTextEntry && uiView.text == text
let isTextModifiedOutsideTextField = text != context.coordinator.textFieldInput
if didUserJustDidStartEditing || isTextModifiedOutsideTextField {
uiView.text?.removeAll()
uiView.insertText(text)
}
}

func makeCoordinator() -> Coordinator {
Coordinator(text: $text, isEditing: $isEditing, onEditingChanged: onEditingChanged, onCommit: onCommit)
}

class Coordinator: NSObject, UITextFieldDelegate {
var text: Binding<String>
var isEditing: Binding<Bool>
let onEditingChanged: (Bool) -> Void
let onCommit: () -> Void

private(set) lazy var textFieldInput: String = text.wrappedValue

init(text: Binding<String>,
isEditing: Binding<Bool>,
onEditingChanged: @escaping (Bool) -> Void,
onCommit: @escaping () -> Void
) {
self.text = text
self.isEditing = isEditing
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
}

func textFieldDidBeginEditing(_ textField: UITextField) {
isEditing.wrappedValue = true
onEditingChanged(isEditing.wrappedValue)
}

func textFieldDidEndEditing(_ textField: UITextField) {
if textField.isFirstResponder {
textField.resignFirstResponder()
}

text.wrappedValue = textField.text ?? ""
textFieldInput = text.wrappedValue
onCommit()

isEditing.wrappedValue = false
onEditingChanged(isEditing.wrappedValue)
}

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {

if let input = textField.text,
let specialRange = Range(range, in: input),
specialRange.clamped(to: text.wrappedValue.startIndex..<text.wrappedValue.endIndex) == specialRange {

text.wrappedValue.replaceSubrange(specialRange, with: string)
textFieldInput = text.wrappedValue
} else {
assertionFailure("Unexpected flow. Please report an issue.")
}

return true
}
}
}

0 comments on commit 2549ae9

Please sign in to comment.