-
-
Save quickbirdstudios-eng/48879025ee8ebcd42bcd28f55fa3cced to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import SwiftUI | |
extension View { | |
func labelled(label: String) -> some View { | |
HStack { | |
Text(label) | |
Spacer() | |
self.fixedSize().multilineTextAlignment(.trailing) | |
} | |
} | |
} | |
// enums can be comparable by default since Swift 5.3 | |
enum ShirtSize: Comparable, CaseIterable { | |
case s | |
case m | |
case l | |
case xl | |
case xxl | |
} | |
extension Optional { | |
func filter(condition: (Wrapped) -> Bool) -> Self { | |
flatMap { condition($0) ? $0 : nil } | |
} | |
} | |
protocol Restriction { | |
associatedtype Value | |
func condition(value: Value) -> Bool | |
} | |
struct Restricted<Value: Comparable, RestrictionType: Restriction> where RestrictionType.Value == Value { | |
let value: Value | |
let restriction: RestrictionType | |
init?(_ value: Value, _ restriction: RestrictionType) { | |
guard restriction.condition(value: value) else { return nil } | |
self.value = value | |
self.restriction = restriction | |
} | |
} | |
extension Restricted { | |
func copy(value: Value? = nil, restriction: RestrictionType? = nil) -> Self? { | |
.init(value ?? self.value, restriction ?? self.restriction) | |
} | |
} | |
struct RangeRestriction<Value: Comparable>: Restriction { | |
let range: ClosedRange<Value> | |
func condition(value: Value) -> Bool { | |
range.contains(value) | |
} | |
} | |
extension RangeRestriction: Codable where Value: Codable {} | |
extension Restricted: Codable where Value: Codable, RestrictionType: Codable {} | |
extension Restricted where RestrictionType == RangeRestriction<Value> { | |
init?(_ value: Value, in range: ClosedRange<Value>) { | |
self.init(value, RangeRestriction(range: range)) | |
} | |
static func clamped(_ value: Value, in range: ClosedRange<Value>) -> Self { | |
let clampedValue: Value | |
if value < range.lowerBound { | |
clampedValue = range.lowerBound | |
} else if range.upperBound < value { | |
clampedValue = range.upperBound | |
} else { | |
clampedValue = value | |
} | |
return .init(clampedValue, in: range)! | |
} | |
} | |
typealias RestrictedToRange<Value: Comparable> = Restricted<Value, RangeRestriction<Value>> | |
struct Settings { | |
var age: RestrictedToRange<UInt> | |
var shirtSize: RestrictedToRange<ShirtSize> | |
var percentage: RestrictedToRange<Double> | |
} | |
extension Settings { | |
static let `default` = Settings(age: .clamped(25, in: 18...30), | |
shirtSize: .clamped(.m, in: .m ... .xl), | |
percentage: .clamped(82.2, in: 0...100)) | |
} | |
struct InputRestriction<Value: Equatable> { | |
private let parse: (String) -> Value? | |
private let toString: (Value) -> String | |
private let allowedInvalidInput: (String) -> AllowedInvalidInput? | |
struct Result { | |
let value: Value | |
let string: String | |
let isValid: Bool | |
fileprivate init(_ value: Value, _ string: String, _ isValid: Bool = true) { | |
self.value = value | |
self.string = string | |
self.isValid = isValid | |
} | |
} | |
struct AllowedInvalidInput { | |
let value: Value? | |
} | |
init(parse: @escaping (String) -> Value?, | |
toString: @escaping (Value) -> String, | |
allowedInvalidInput: @escaping (String) -> AllowedInvalidInput? = { _ in nil }) { | |
self.allowedInvalidInput = allowedInvalidInput | |
self.parse = parse | |
self.toString = toString | |
} | |
func evaluate(value: Value, string: String? = nil) -> Result { | |
guard let string else { return .init(value, toString(value)) } | |
func handleValidInput(newValue: Value) -> Result { | |
let newValueString = toString(newValue) | |
if (newValueString.count <= string.count) { | |
return .init(newValue, newValueString) | |
} else { | |
return .init(newValue, string) | |
} | |
} | |
func handleInvalidInput() -> Result { | |
if let invalidValue = allowedInvalidInput(string) { | |
if let newInvalidValueString = invalidValue.value.map(toString), | |
newInvalidValueString.count <= string.count { | |
return .init(value, newInvalidValueString, false) | |
} else { | |
return .init(value, string, false) | |
} | |
} else { | |
let currentValueString = toString(value) | |
if currentValueString.hasPrefix(string) { | |
return .init(value, "") | |
} else { | |
return .init(value, toString(value)) | |
} | |
} | |
} | |
if let newValue = parse(string) { | |
return handleValidInput(newValue: newValue) | |
} else { | |
return handleInvalidInput() | |
} | |
} | |
} | |
extension InputRestriction { | |
func copy(parse: ((String) -> Value?)? = nil, | |
toString: ((Value) -> String)? = nil, | |
allowedInvalidInput: ((String) -> AllowedInvalidInput?)? = nil) -> Self { | |
.init(parse: parse ?? self.parse, | |
toString: toString ?? self.toString, | |
allowedInvalidInput: allowedInvalidInput ?? self.allowedInvalidInput) | |
} | |
} | |
// Note: Using LosslessStringConvertible is not localized! | |
extension InputRestriction where Value: LosslessStringConvertible, Value: Comparable { | |
init(restricted: RestrictedToRange<Value>, allowedInvalidInput: @escaping (String) -> AllowedInvalidInput?) { | |
let range = restricted.restriction.range | |
let parse: (String) -> Value? = { | |
guard let value = Value.init($0), range.contains(value) else { return nil } | |
return value | |
} | |
self.init(parse: parse, | |
toString: \.description, | |
allowedInvalidInput: allowedInvalidInput) | |
} | |
} | |
extension InputRestriction | |
where Value: LosslessStringConvertible, | |
Value: Comparable, | |
Value: ExpressibleByIntegerLiteral { | |
init(restricted: RestrictedToRange<Value>) { | |
let range = restricted.restriction.range | |
let parse: (String) -> Value? = { | |
guard let value = Value.init($0), range.contains(value) else { return nil } | |
return value | |
} | |
let allowedInvalidRange = 0..<range.lowerBound | |
let allowedInvalidInput = { (string: String) in | |
Value.init(string) | |
.filter { allowedInvalidRange.contains($0) } | |
.map(AllowedInvalidInput.init) | |
} | |
self.init(parse: parse, | |
toString: \.description, | |
allowedInvalidInput: allowedInvalidInput) | |
} | |
} | |
struct RestrictedView<Value: Equatable, Content: View>: View { | |
private let restriction: InputRestriction<Value> | |
private let content: (Binding<String>, Bool) -> Content | |
@Binding private var value: Value | |
@State private var string: String | |
@State private var isValid: Bool | |
var body: some View { | |
content($string, isValid).onChange(of: string, perform: update) | |
} | |
init(for value: Binding<Value>, | |
by restriction: InputRestriction<Value>, | |
@ViewBuilder content: @escaping (Binding<String>, Bool) -> Content) { | |
self._value = value | |
self.restriction = restriction | |
self.content = content | |
let initial = restriction.evaluate(value: value.wrappedValue) | |
self._isValid = .init(initialValue: initial.isValid) | |
self._string = .init(initialValue: initial.string) | |
} | |
private func update(_ string: String) { | |
let result = restriction.evaluate(value: value, string: string) | |
value = result.value | |
self.string = result.string | |
isValid = result.isValid | |
} | |
} | |
struct RestrictedTextField<Value: Equatable, Modifier: ViewModifier>: View { | |
private let value: Binding<Value> | |
private let restriction: InputRestriction<Value> | |
private let prompt: String | |
private let onInput: (Bool) -> Modifier | |
var body: some View { | |
RestrictedView(for: value, by: restriction) { $text, isValid in | |
TextField(prompt, text: $text) | |
.modifier(onInput(isValid)) | |
} | |
} | |
init(for value: Binding<Value>, | |
by restriction: InputRestriction<Value>, | |
prompt: String = "", | |
onInput: @escaping (Bool) -> Modifier) { | |
self.value = value | |
self.restriction = restriction | |
self.prompt = prompt | |
self.onInput = onInput | |
} | |
} | |
struct DefaultInputViewModifier: ViewModifier { | |
private let isValid: Bool | |
func body(content: Content) -> some View { | |
content.foregroundColor(isValid ? .primary : .red) | |
} | |
fileprivate init(isValid: Bool) { | |
self.isValid = isValid | |
} | |
} | |
extension RestrictedTextField | |
where Value: Comparable, | |
Value: LosslessStringConvertible, | |
Modifier == DefaultInputViewModifier { | |
init(for value: Binding<RestrictedToRange<Value>>, | |
allowedInvalidInput: @escaping (String) -> InputRestriction<Value>.AllowedInvalidInput? = { _ in nil }, | |
prompt: String? = nil) { | |
self.init(for: value, allowedInvalidInput: allowedInvalidInput, prompt: prompt, onInput: { .init(isValid: $0) }) | |
} | |
} | |
extension RestrictedTextField | |
where Value: LosslessStringConvertible, | |
Value: Comparable, | |
Value: ExpressibleByIntegerLiteral, | |
Modifier == DefaultInputViewModifier { | |
init(for value: Binding<RestrictedToRange<Value>>, prompt: String? = nil) { | |
let allowedInvalidRange = 0..<value.wrappedValue.restriction.range.lowerBound | |
self.init(for: value, | |
allowedInvalidInput: { | |
Value.init($0) | |
.filter { allowedInvalidRange.contains($0) } | |
.map(InputRestriction<Value>.AllowedInvalidInput.init) | |
}, | |
prompt: prompt) | |
} | |
} | |
extension RestrictedTextField where Value: Comparable, Value: LosslessStringConvertible { | |
init(for value: Binding<RestrictedToRange<Value>>, | |
allowedInvalidInput: @escaping (String) -> InputRestriction<Value>.AllowedInvalidInput? = { _ in nil }, | |
prompt: String? = nil, | |
onInput: @escaping (Bool) -> Modifier) { | |
let restrictedValue = value.wrappedValue | |
let binding = Binding<Value> { | |
value.wrappedValue.value | |
} set: { newValue in | |
guard let newRestrictedValue = restrictedValue.copy(value: newValue) | |
else { fatalError("Bug in the implementation (Probably InputRestriction)!") } | |
value.wrappedValue = newRestrictedValue | |
} | |
self.init(for: binding, | |
by: .init(restricted: restrictedValue, allowedInvalidInput: allowedInvalidInput), | |
prompt: prompt ?? restrictedValue.restriction.range.description, | |
onInput: onInput) | |
} | |
} | |
// Not very nice, just example -> A RestrictedViewPicker would make way more sense | |
extension ShirtSize: LosslessStringConvertible { | |
init?(_ description: String) { | |
let allSizes = Dictionary(uniqueKeysWithValues: ShirtSize.allCases.map { ($0.description, $0) }) | |
guard let size = allSizes[description.uppercased()] else { return nil } | |
self = size | |
} | |
var description: String { | |
switch self { | |
case .s: return "S" | |
case .m: return "M" | |
case .l: return "L" | |
case .xl: return "XL" | |
case .xxl: return "XXL" | |
} | |
} | |
} | |
extension InputRestriction where Value == ShirtSize { | |
static func allowedInvalidInput(string: String) -> AllowedInvalidInput? { | |
string.uppercased() == "X" ? AllowedInvalidInput(value: nil) : nil | |
} | |
} | |
class SettingsViewModel: ObservableObject { | |
@Published public var settings: Settings = .default | |
} | |
struct SettingsView: View { | |
@ObservedObject private var viewModel = SettingsViewModel() | |
var body: some View { | |
List { | |
RestrictedTextField(for: $viewModel.settings.age) | |
.labelled(label: "Age:") | |
.keyboardType(.numberPad) | |
RestrictedTextField(for: $viewModel.settings.shirtSize, | |
allowedInvalidInput: InputRestriction.allowedInvalidInput) | |
.labelled(label: "Shirt size:") | |
RestrictedTextField(for: $viewModel.settings.percentage) | |
.labelled(label: "Percentage:") | |
.keyboardType(.decimalPad) | |
} | |
} | |
} | |
@main | |
struct MyApp: App { | |
var body: some Scene { | |
WindowGroup { | |
SettingsView() | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment