Skip to content

Instantly share code, notes, and snippets.

@end3r117
Last active September 23, 2020 04:20
Show Gist options
  • Save end3r117/058e177aaf9f1e67891ff6123e3918a8 to your computer and use it in GitHub Desktop.
Save end3r117/058e177aaf9f1e67891ff6123e3918a8 to your computer and use it in GitHub Desktop.
202009022 - SwiftUI - iOS 14 - Tip Calculator / Toggle Button example
//
// 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