Add PinCodeTextField

This commit is contained in:
Brandon Evans 2020-12-01 20:35:50 -07:00
parent ad267e2b56
commit fb6ed58b3e
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
3 changed files with 235 additions and 1 deletions

View file

@ -10,6 +10,7 @@
CA378F992466567600A58CE0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA378F982466567600A58CE0 /* AppState.swift */; };
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA39711824495F0E00AFFB77 /* AppStoreButtonStyle.swift */; };
CA44901F2463AD34003D8213 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA44901E2463AD34003D8213 /* Tag.swift */; };
CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */; };
CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; };
CAA1CB2F255A5262003FD669 /* XcodesKit in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2E255A5262003FD669 /* XcodesKit */; };
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; };
@ -40,6 +41,7 @@
CA44901E2463AD34003D8213 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = "<group>"; };
CA538A0C255A4F1A00E64DD7 /* AppleAPI */ = {isa = PBXFileReference; lastKnownFileType = folder; name = AppleAPI; path = Xcodes/AppleAPI; sourceTree = "<group>"; };
CA538A0F255A4F3300E64DD7 /* XcodesKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = XcodesKit; path = Xcodes/XcodesKit; sourceTree = "<group>"; };
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCodeTextView.swift; sourceTree = "<group>"; };
CA8FB5F8256E0F9400469DA5 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
CA8FB61C256E115700469DA5 /* .github */ = {isa = PBXFileReference; lastKnownFileType = folder; path = .github; sourceTree = "<group>"; };
CA8FB64D256E17B100469DA5 /* XcodesTest.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = XcodesTest.entitlements; sourceTree = "<group>"; };
@ -92,6 +94,7 @@
children = (
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */,
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */,
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */,
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */,
CAA1CB4C255A5CFD003FD669 /* SignInPhoneListView.swift */,
);
@ -272,6 +275,7 @@
CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */,
CAA1CB4D255A5CFD003FD669 /* SignInPhoneListView.swift in Sources */,
CAD2E7A22449574E00113D76 /* XcodesApp.swift in Sources */,
CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */,
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -0,0 +1,222 @@
import Cocoa
import SwiftUI
struct PinCodeTextField: NSViewRepresentable {
typealias NSViewType = PinCodeTextView
@Binding var code: String
let numberOfDigits: Int
func makeNSView(context: Context) -> NSViewType {
let view = PinCodeTextView(numberOfDigits: numberOfDigits, itemSpacing: 10)
view.codeDidChange = { c in code = c }
return view
}
func updateNSView(_ nsView: NSViewType, context: Context) {
nsView.code = (0..<numberOfDigits).map { index in
if index < code.count {
let codeIndex = code.index(code.startIndex, offsetBy: index)
return code[codeIndex]
} else {
return nil
}
}
}
}
struct PinCodeTextField_Previews: PreviewProvider {
struct PreviewContainer: View {
@State private var code = "123"
var body: some View {
PinCodeTextField(code: $code, numberOfDigits: 6)
.padding()
}
}
static var previews: some View {
Group {
PreviewContainer()
}
}
}
// MARK: - PinCodeTextView
class PinCodeTextView: NSControl, NSTextFieldDelegate {
var code: [Character?] = [] {
didSet {
guard code != oldValue else { return }
if let handler = codeDidChange {
handler(String(code.compactMap { $0 }))
}
updateText()
}
}
var codeDidChange: ((String) -> Void)? = nil
private let numberOfDigits: Int
private let stackView: NSStackView = .init(frame: .zero)
private var characterViews: [PinCodeCharacterTextField] = []
// MARK: - Initializers
init(
numberOfDigits: Int,
itemSpacing: CGFloat
) {
self.numberOfDigits = numberOfDigits
super.init(frame: .zero)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.spacing = itemSpacing
stackView.orientation = .horizontal
stackView.distribution = .fillEqually
stackView.alignment = .centerY
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: self.topAnchor),
stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
stackView.leadingAnchor.constraint(greaterThanOrEqualTo: self.leadingAnchor),
stackView.trailingAnchor.constraint(greaterThanOrEqualTo: self.trailingAnchor),
stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
])
self.code = (0..<numberOfDigits).map { _ in nil }
self.characterViews = (0..<numberOfDigits).map { _ in
let view = PinCodeCharacterTextField()
view.translatesAutoresizingMaskIntoConstraints = false
view.delegate = self
return view
}
characterViews.forEach {
stackView.addArrangedSubview($0)
stackView.heightAnchor.constraint(equalTo: $0.heightAnchor).isActive = true
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateText() {
characterViews.enumerated().forEach { (index, item) in
if (0..<code.count).contains(index) {
let _index = code.index(code.startIndex, offsetBy: index)
item.character = code[_index]
} else {
item.character = nil
}
}
}
// MARK: NSTextFieldDelegate
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(deleteBackward(_:)) {
// If empty, move to previous or first character view
if textView.string.isEmpty {
if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) {
window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter])
} else {
window?.makeFirstResponder(characterViews[0])
}
return true
}
}
// Perform default behaviour
return false
}
func controlTextDidChange(_ obj: Notification) {
guard
let field = obj.object as? NSTextField,
isEnabled,
let fieldIndex = characterViews.firstIndex(where: { $0 === field })
else { return }
let newFieldText = field.stringValue
let lastCharacter: Character?
if newFieldText.isEmpty {
lastCharacter = nil
} else {
lastCharacter = newFieldText[newFieldText.index(before: newFieldText.endIndex)]
}
code[fieldIndex] = lastCharacter
if lastCharacter != nil {
if fieldIndex >= characterViews.count - 1 {
resignFirstResponder()
} else {
window?.makeFirstResponder(characterViews[fieldIndex + 1])
}
} else {
if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) {
window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter])
} else {
window?.makeFirstResponder(characterViews[0])
}
}
}
// MARK: NSResponder
override var acceptsFirstResponder: Bool {
true
}
override func becomeFirstResponder() -> Bool {
characterViews.first?.becomeFirstResponder() ?? false
}
}
// MARK: - PinCodeCharacterTextField
class PinCodeCharacterTextField: NSTextField {
var character: Character? = nil {
didSet {
stringValue = character.map(String.init) ?? ""
}
}
private var lastSize: NSSize?
init() {
super.init(frame: .zero)
wantsLayer = true
alignment = .center
maximumNumberOfLines = 1
font = .boldSystemFont(ofSize: 48)
setContentHuggingPriority(.required, for: .vertical)
setContentHuggingPriority(.required, for: .horizontal)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
self.invalidateIntrinsicContentSize()
}
// This is kinda cheating
// Assuming that 0 is the widest and tallest character in 0-9
override var intrinsicContentSize: NSSize {
var size = NSAttributedString(
string: "0",
attributes: [ .font : self.font! ]
)
.size()
// I guess the cell should probably be doing this sizing in order to take into account everything outside of simply the text's frame, but for some reason I can't find a way to do that which works...
size.width += 8
size.height += 8
return size
}
}

View file

@ -6,20 +6,28 @@ struct SignIn2FAView: View {
@Binding var isPresented: Bool
@State private var code: String = ""
let sessionData: AppleSessionData
// TODO: dynamic number of digits
let numberOfDigits = 6
var body: some View {
VStack(alignment: .leading) {
Text("Enter the \(6) digit code from one of your trusted devices:")
HStack {
TextField("\(6) digit code", text: $code)
Spacer()
PinCodeTextField(code: $code, numberOfDigits: numberOfDigits)
Spacer()
}
.padding()
HStack {
Button("Cancel", action: { isPresented = false })
.keyboardShortcut(.cancelAction)
Button("Send SMS", action: {})
Spacer()
Button("Continue", action: { appState.submit2FACode(code, sessionData: sessionData) })
.keyboardShortcut(.defaultAction)
.disabled(code.count != numberOfDigits)
}
}
.padding()