Skip to content

Instantly share code, notes, and snippets.

@quickbirdstudios-eng
Created April 17, 2023 07:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save quickbirdstudios-eng/48879025ee8ebcd42bcd28f55fa3cced to your computer and use it in GitHub Desktop.
Save quickbirdstudios-eng/48879025ee8ebcd42bcd28f55fa3cced to your computer and use it in GitHub Desktop.
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