Last active
September 23, 2020 04:20
-
-
Save end3r117/058e177aaf9f1e67891ff6123e3918a8 to your computer and use it in GitHub Desktop.
202009022 - SwiftUI - iOS 14 - Tip Calculator / Toggle Button example
This file contains hidden or 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
// | |
// TipCalcExample.swift | |
// MuckAbout | |
// | |
// Created by End3r117 on 9/22/20. | |
// | |
import SwiftUI | |
extension UIResponder { | |
static var currentFirstResponder: UIResponder? { | |
_currentFirstResponder = nil | |
UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder(_:)), to: nil, from: nil, for: nil) | |
return _currentFirstResponder | |
} | |
private static weak var _currentFirstResponder: UIResponder? | |
static func resignCurrentFirstResponder() { | |
currentFirstResponder?.resignFirstResponder() | |
} | |
@objc private func findFirstResponder(_ sender: Any) { | |
UIResponder._currentFirstResponder = self | |
} | |
} | |
struct PercentageLabel: View { | |
enum Percentage: RawRepresentable, Identifiable, CaseIterable, Hashable { | |
typealias AllCases = [Self] | |
static var allCases: AllCases { [.five, .ten, .twenty] } | |
static let currencyFormatter: NumberFormatter = { | |
let f = NumberFormatter() | |
f.allowsFloats = true | |
f.isLenient = true | |
f.generatesDecimalNumbers = true | |
f.locale = .current | |
f.numberStyle = .currency | |
f.currencyCode = Locale.current.currencyCode | |
return f | |
}() | |
var id: String { self.rawValue } | |
case other(Decimal), five, ten, twenty | |
init?(rawValue: String) { | |
for val in Self.allCases { | |
if val.rawValue == rawValue { | |
self = val | |
} | |
} | |
return nil | |
} | |
var isOther: Bool { | |
switch self { | |
case .other: return true | |
default: return false | |
} | |
} | |
var rawValue: String { | |
switch self { | |
case .five: return "5%" | |
case .ten: return "10%" | |
case .twenty: return "20%" | |
case .other: | |
let double = (percentage * 100 as NSDecimalNumber).doubleValue | |
return "\(String(format: "%0.2f", double))%" | |
} | |
} | |
var percentage: Decimal { | |
switch self { | |
case .five: return 0.05 | |
case .ten: return 0.10 | |
case .twenty: return 0.20 | |
case .other(let num): return num | |
} | |
} | |
static func decimalToCurrencyString(_ dec: Decimal) -> String? { | |
guard let str = currencyFormatter.string(from: dec as NSDecimalNumber) else { return nil } | |
return str | |
} | |
} | |
let id: Percentage | |
var toggled: Bool | |
let onColor: Color | |
let offColor: Color | |
var body: some View { | |
Text(id.rawValue) | |
.font(.headline) | |
.padding() | |
.foregroundColor(toggled ? offColor : onColor) | |
.frame(minWidth: 80) | |
.background(toggled ? onColor : offColor) | |
.cornerRadius(8) | |
} | |
} | |
struct ToggleButton<ID, Label>: View, Identifiable where ID : Hashable, Label: View { | |
@Binding var selection: ID? | |
let id: ID | |
let label: (Bool) -> Label | |
init(_ id: ID, selection: Binding<ID?>, @ViewBuilder label: @escaping (Bool) -> Label) { | |
self.id = id | |
self._selection = selection | |
self.label = label | |
} | |
var toggled: Bool { | |
selection == id | |
} | |
var body: some View { | |
Button { | |
selection = selection == id ? nil : id | |
} label: { | |
label(toggled) | |
} | |
.animation(.interactiveSpring()) | |
} | |
} | |
//MARK:- Previews | |
struct TipCalcTestPreview: View { | |
typealias Percentage = PercentageLabel.Percentage | |
//MARK: State | |
@State private var tip: Percentage? = nil | |
@State private var otherAmt: Decimal? = nil | |
@State private var showOther: Bool = false | |
@State private var showSend: Bool = true | |
@State private var done: Bool = false | |
@State private var subtotal: Decimal = getSubtotal() | |
//MARK:- Body | |
var body: some View { | |
ZStack(alignment: .bottom) { | |
if done { | |
Color.green | |
.edgesIgnoringSafeArea(.all) | |
.matchedGeometryEffect(id: "send", in: ns) | |
.animation(.easeIn) | |
Group { | |
VStack { | |
Spacer() | |
Text("Payment Sent!") | |
.matchedGeometryEffect(id: "Text", in: ns, isSource: true) | |
.fixedSize() | |
.transition(.opacity) | |
.font(.largeTitle) | |
.foregroundColor(.white) | |
Spacer() | |
} | |
} | |
.animation(.easeIn) | |
} | |
if !done { | |
Form { | |
subtotalSection | |
tipSection | |
summarySection | |
} | |
.font(.subheadline) | |
.gesture(gesture) | |
.transition(formTransition) | |
.scaleEffect(max(1 - (dragUp + 1) / 400, 0.5)) | |
.offset(y: -dragUp / 2) | |
.animation(.easeIn) | |
} | |
if showSend { | |
sendButton | |
} | |
} | |
} | |
//MARK:- Animations / Transitions | |
var formTransition: AnyTransition { | |
AnyTransition.asymmetric(insertion: AnyTransition.opacity, removal: AnyTransition.move(edge: .top).combined(with: .opacity)).animation(.easeOut(duration: 0.4)) | |
} | |
var gesture: some Gesture { | |
SimultaneousGesture( | |
TapGesture() | |
.onEnded(dismiss) | |
, DragGesture() | |
.onChanged({_ in dismiss() }) | |
) | |
} | |
//MARK:- Buttons | |
@Namespace var ns | |
@GestureState var dragUp: CGFloat = 0 | |
var sendButton: some View { | |
Color.green | |
.saturation(tip == nil ? 0.5 : 1) | |
.ignoresSafeArea(.all, edges: .vertical) | |
.matchedGeometryEffect(id: "send", in: ns) | |
.overlay(VStack(spacing: 18) { Image(systemName: "chevron.up").font(.callout).scaleEffect(done ? 0 : 1).foregroundColor(tip == nil ? Color(UIColor.white.withAlphaComponent(0.8)) : .white).padding(.top);Text(done ? "Payment Sent!" : "SEND").frame(maxWidth: .infinity).matchedGeometryEffect(id: "Text", in: ns).font(.title).animation(Animation.easeIn(duration: done ? 0.1 : 0.3)).foregroundColor(.white).multilineTextAlignment(.center) }) | |
.frame(maxHeight: 80 + dragUp) | |
.animation(.spring()) | |
.gesture( | |
DragGesture() | |
.updating($dragUp, body: { (val, state, _) in | |
if tip != nil { | |
withAnimation { | |
state = -val.translation.height | |
} | |
}else if tip == nil, abs(val.translation.height) <= 20 { | |
state = -val.translation.height | |
} | |
}) | |
.onChanged({ (val) in | |
if tip != nil, abs(val.translation.height) >= UIScreen.main.bounds.height / 4 { | |
send() | |
} | |
}) | |
) | |
} | |
var otherTipButton: some View { | |
Button { | |
withAnimation { | |
tip = nil | |
showOther = true | |
} | |
} label: { | |
Group { | |
if tip?.isOther ?? false { | |
if let t = tip { | |
Text(t.rawValue) | |
.font(.headline) | |
.padding() | |
.foregroundColor(.white) | |
.frame(minWidth: 80) | |
.background(RoundedRectangle(cornerRadius: 8).fill(Color.green)) | |
} | |
}else { | |
Text("Other") | |
.font(.headline) | |
.padding() | |
.foregroundColor(.white) | |
.frame(minWidth: 80) | |
.background(RoundedRectangle(cornerRadius: 8).strokeBorder(Color.white)) | |
.scaleEffect(showOther ? 0 : 1) | |
.opacity(showOther ? 0 : 1) | |
} | |
} | |
} | |
.id(Percentage.other(0)) | |
.padding(8) | |
.buttonStyle(PlainButtonStyle()) | |
} | |
//MARK:- Form Sections | |
var subtotalSection: some View { | |
Group { | |
if let sub = Percentage.decimalToCurrencyString(subtotal) { | |
Section { | |
HStack { | |
Text("Subtotal") | |
.foregroundColor(.secondary) | |
Spacer() | |
Text(sub) | |
.transition(.identity) | |
.animation(nil) | |
} | |
} | |
} | |
} | |
} | |
var tipSection: some View { | |
Section(header: Text("Add tip")) { | |
ScrollViewReader { reader in | |
ScrollView(.horizontal, showsIndicators: false) { | |
HStack { | |
ForEach(Percentage.allCases) { pct in | |
ToggleButton(pct, selection: $tip) { toggled in | |
PercentageLabel(id: pct, toggled: toggled, onColor: Color.green, offColor: Color.white) | |
} | |
.id(pct) | |
.transition(.scale) | |
.animation(.easeOut) | |
.scaleEffect(showOther ? 0 : 1) | |
.opacity(showOther ? 0 : 1) | |
} | |
.padding(8) | |
.onChange(of: tip){ value in | |
if let tip = value { | |
withAnimation { | |
reader.scrollTo(tip.isOther ? Percentage.other(0) : tip, anchor: .center) | |
} | |
if !tip.isOther { | |
otherAmt = nil | |
} | |
} | |
} | |
otherTipButton | |
Spacer() | |
} | |
} | |
.overlay( | |
VStack { | |
Spacer() | |
HStack { | |
if showOther { | |
TextField("\(Percentage.currencyFormatter.currencySymbol ?? "$")0.00", value: $otherAmt, formatter: Percentage.currencyFormatter, | |
onEditingChanged: { | |
showSend = !$0 | |
}, onCommit: { | |
withAnimation(.easeIn) { | |
if let otherAmt = otherAmt, otherAmt >= 0 { | |
tip = .other(otherAmt / subtotal) | |
} | |
showOther = false | |
} | |
}) | |
.contentShape(Rectangle()) | |
.transition(AnyTransition.scale.combined(with: .opacity)) | |
.animation(.easeOut) | |
.font(.body) | |
.keyboardType(.decimalPad) | |
.padding() | |
.overlay( | |
HStack { | |
Spacer() | |
Button { | |
withAnimation { | |
otherAmt = nil | |
tip = nil | |
showOther = false | |
UIResponder.resignCurrentFirstResponder() | |
} | |
} label: { | |
Image(systemName: "x.circle.fill") | |
.font(.headline) | |
.foregroundColor(.green) | |
} | |
.transition(.opacity) | |
.animation(.easeOut) | |
} | |
) | |
Spacer() | |
} | |
} | |
Spacer() | |
} | |
) | |
} | |
.listRowBackground(showOther ? Color(.secondarySystemGroupedBackground) : Color.green.opacity(tip == nil ? min(0.4 + Double(dragUp / 100), 0.8) : 0.4)) | |
} | |
.buttonStyle(PlainButtonStyle()) | |
} | |
var summarySection: some View { | |
Section { | |
VStack(spacing: 10) { | |
HStack { | |
Text("Tip") | |
.foregroundColor(.secondary) | |
.padding(.vertical) | |
Spacer() | |
if let tip = tip, let tipAmt = Percentage.decimalToCurrencyString(subtotal * tip.percentage) { | |
Text("+ \(tipAmt)") | |
}else { | |
Text("-") | |
.foregroundColor(.secondary) | |
} | |
} | |
.transition(.opacity) | |
.animation(.interactiveSpring()) | |
Divider() | |
HStack { | |
Group { | |
Text("Total") | |
.foregroundColor(.secondary) | |
Spacer() | |
if let tip = tip, let total = Percentage.decimalToCurrencyString((subtotal + (subtotal * tip.percentage))) { | |
Text(total) | |
}else if let sub = Percentage.decimalToCurrencyString(subtotal) { | |
Text(sub) | |
} | |
} | |
.font(.headline) | |
} | |
.padding(.bottom, 8) | |
} | |
} | |
} | |
//MARK:- Methods | |
private func send() { | |
withAnimation { | |
done = true | |
tip = nil | |
otherAmt = nil | |
showOther = false | |
subtotal = Self.getSubtotal() | |
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | |
withAnimation { | |
done = false | |
} | |
} | |
} | |
} | |
private func dismiss() { | |
withAnimation { | |
if UIResponder.currentFirstResponder != nil { | |
if let fr = UIResponder.currentFirstResponder as? UITextField, let txt = fr.text { | |
if let amt = Percentage.currencyFormatter.number(from: txt) { | |
otherAmt = amt.decimalValue | |
tip = .other(amt.decimalValue / subtotal) | |
} | |
} | |
if showOther { | |
showOther = false | |
} | |
UIResponder.resignCurrentFirstResponder() | |
} | |
} | |
} | |
static private func getSubtotal() -> Decimal { | |
(Double.random(in: 5...100) as NSNumber).decimalValue | |
} | |
} | |
struct TipCalcTest_Previews: PreviewProvider { | |
static var previews: some View { | |
TipCalcTestPreview() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment