Animating the letters in a word w the app opens - SwiftUI - animation

I'm trying to get a dancing letters effect when my app first opens.
I'm close. The coding below almost does what I want. I use a ForEach loop to loop through the letters of the word and apply an animation to each letter. And I use the onAppear function to set the drag amount when the app opens.
With this coding I can get the 'forward' motion but I can't get the animation to reverse so that the letters end up in their original position. I've tried adding a repeat with reverse, but, again, the letters never return to their original position
Does anyone have any idea how to do this?
struct ContentView: View {
let letters = Array("Math Fun!")
#State private var enabled = false
#State private var dragAmount = CGSize.zero
var body: some View {
HStack(spacing: 0) {
ForEach(0..<letters.count) { num in
Text(String(self.letters[num]))
.padding(5)
.font(.title)
.background(self.enabled ? Color.blue : Color.red)
.offset(self.dragAmount)
.animation(Animation.default.delay(Double(num)/20).repeatCount(3, autoreverses: true))
}
}
.onAppear {
self.dragAmount = CGSize(width: 0, height: 80)
self.enabled.toggle()
}
}
}

Update: with Xcode 13.4 / iOS 15.5
Animation is based on changed states, we switched states and view animated to the new states, so to rollback we need to switch the states back.
Here is the possible approach (might still require tuning, but is ok for demo)
struct ContentView: View {
let letters = Array("Math Fun!")
#State private var enabled = false
#State private var dragAmount = CGSize.zero
var body: some View {
HStack(spacing: 0) {
ForEach(0..<letters.count, id: \.self) { num in
Text(String(self.letters[num]))
.padding(5)
.font(.title)
.background(self.enabled ? Color.blue : Color.red)
.offset(self.dragAmount)
.animation(Animation.default.delay(Double(num)/20), value: enabled)
}
}
.onAppear {
self.dragAmount = CGSize(width: 0, height: 80)
self.enabled.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.dragAmount = .zero
self.enabled.toggle()
}
}
}
}

You can use AnimatableModifier to achieve this effect. here is a sample code:
extension Double {
var rad: Double { return self * .pi / 180 }
var deg: Double { return self * 180 / .pi }
}
struct ContentView: View {
#State private var flag = false
var body: some View {
VStack {
Spacer()
Color.clear.overlay(WaveText("Your Text That Need Animate", waveWidth: 6, pct: flag ? 1.0 : 0.0).foregroundColor(.blue)).frame(height: 40)
Spacer()
}.onAppear {
withAnimation(Animation.easeInOut(duration: 2.0).repeatForever()) {
self.flag.toggle()
}
}
}
}
struct WaveText: View {
let text: String
let pct: Double
let waveWidth: Int
var size: CGFloat
init(_ text: String, waveWidth: Int, pct: Double, size: CGFloat = 34) {
self.text = text
self.waveWidth = waveWidth
self.pct = pct
self.size = size
}
var body: some View {
Text(text).foregroundColor(Color.clear).modifier(WaveTextModifier(text: text, waveWidth: waveWidth, pct: pct, size: size))
}
struct WaveTextModifier: AnimatableModifier {
let text: String
let waveWidth: Int
var pct: Double
var size: CGFloat
var animatableData: Double {
get { pct }
set { pct = newValue }
}
func body(content: Content) -> some View {
HStack(spacing: 0) {
ForEach(Array(text.enumerated()), id: \.0) { (n, ch) in
Text(String(ch))
.scaleEffect(self.effect(self.pct, n, self.text.count, Double(self.waveWidth)))
}
}
}
func effect(_ pct: Double, _ n: Int, _ total: Int, _ waveWidth: Double) -> CGFloat {
let n = Double(n)
let total = Double(total)
return CGFloat(1 + valueInCurve(pct: pct, total: total, x: n/total, waveWidth: waveWidth))
}
func valueInCurve(pct: Double, total: Double, x: Double, waveWidth: Double) -> Double {
let chunk = waveWidth / total
let m = 1 / chunk
let offset = (chunk - (1 / total)) * pct
let lowerLimit = (pct - chunk) + offset
let upperLimit = (pct) + offset
guard x >= lowerLimit && x < upperLimit else { return 0 }
let angle = ((x - pct - offset) * m)*360-90
return (sin(angle.rad) + 1) / 2
}
}
}
You can find refrence and compelete answer here

Related

SwiftUI DragGesture list content Leaks memory

I am trying to implement Uber trip like screen, and I have used the code below
public struct OverlappingPanView<BGView, PanContentView>: View
where BGView: View, PanContentView: View {
public var backgroundView: () -> BGView
public var panContentView: () -> PanContentView
public init(backgroundView: #escaping () -> BGView, panContentView: #escaping () -> PanContentView) {
self.backgroundView = backgroundView
self.panContentView = panContentView
}
enum OffsetError : Error{
case TopBoundExceded
case BottomBoundExceded
}
//MARK: - State Property Variables
#GestureState private var dragOffset: CGFloat = 0
#State private var lastEndedOffset: CGFloat = 0
#State private var panFrame: CGSize = .zero
//MARK: - Getters
var staticTopPadding: CGFloat{
let screenPadding = UIScreen.main.bounds.height * 0.4
let actualFrame = (panFrame.height - screenPadding)
let extraCorrection = actualFrame * 0.5
return screenPadding + extraCorrection
}
private var currentPaddingValue: CGFloat{
return dragOffset + lastEndedOffset + staticTopPadding
}
private var difference: CGFloat{
return 200
}
//MARK: - Body
public var body: some View {
ZStack {
backgroundView()
.edgesIgnoringSafeArea(.all)
panControllerView()
}
}
func panControllerView() -> some View{
panContentView()
.background(
GeometryReader { geo -> Color in
DispatchQueue.main.async {
self.panFrame = geo.size
}
return Color.clear
}
)
.frame(minHeight: 0, maxHeight: .infinity)
.offset(y: dragOffset + lastEndedOffset + staticTopPadding)
.animation(.interactiveSpring())
.highPriorityGesture(
DragGesture(coordinateSpace: CoordinateSpace.global)
.updating($dragOffset) { (value, state, _) in
if let endValue = try? self.canDrag(value.translation.height){
state = endValue
}
}
.onEnded { value in
self.onDragEnd(value.translation.height)
}
)
}
func onDragEnd(_ value: CGFloat){
withAnimation {
do{
let endValue = try self.canDrag(value)
self.lastEndedOffset = lastEndedOffset + endValue
}catch OffsetError.BottomBoundExceded{
self.lastEndedOffset = 0
}catch OffsetError.TopBoundExceded{
self.lastEndedOffset = -(self.panFrame.height - difference)
}catch{
self.lastEndedOffset = 0
}
}
}
func canDrag(_ value: CGFloat) throws -> CGFloat{
let newValue = value + lastEndedOffset + staticTopPadding
// stop going below
guard newValue <= staticTopPadding else{
throw OffsetError.BottomBoundExceded
}
let topCalculation = (self.panFrame.height + (value + self.lastEndedOffset)) - difference
// stop going top
guard topCalculation >= 0 else{
throw OffsetError.TopBoundExceded
}
return value
}
}
and Used the above code to show mapview in the background and ForEach content on the front.
OverlappingPanView {
GoogleMapView(delegate: presenter)
} panContentView: {
contentView() // ForEach looped data Views
}
The list that I have provided in content View is leaking memory and uses high CPU (above 100%) when dragging leading to UI Performance issues.

In SwiftUI how do I animate changes one at a time when they occur in a called method?

Although I get an animation when I tap the button, it's not the animation I want.
The entire view is being replaced at once, but I want to see each element change in sequence. I tried in both the parent view and in the called method. Neither produces the desired result.
(this is a simplified version of the original code)
import SwiftUI
struct SequencedCell: Identifiable {
let id = UUID()
var value: Int
mutating func addOne() {
value += 1
}
}
struct AQTwo: View {
#State var cells: [SequencedCell]
init() {
_cells = State(initialValue: (0 ..< 12).map { SequencedCell(value: $0) })
}
var body: some View {
VStack {
Spacer()
Button("+") {
sequencingMethod(items: $cells)
}
.font(.largeTitle)
Spacer()
HStack {
ForEach(Array(cells.enumerated()), id: \.1.id) { index, item in
// withAnimation(.linear(duration: 4)) {
Text("\(item.value)").tag(index)
// }
}
}
Spacer()
}
}
func sequencingMethod(items: Binding<[SequencedCell]>) {
for cell in items {
withAnimation(.linear(duration: 4)) {
cell.wrappedValue = SequencedCell(value: cell.wrappedValue.value + 1)
// cell.wrappedValue.addOne()
}
}
}
}
struct AQTwoPreview: PreviewProvider {
static var previews: some View {
AQTwo()
}
}
So I want the 0 to turn into a 1, the 1 then turn into a 2, etc.
Edit:
Even though I have accepted an answer, it answered my question, but didn't solve my issue.
I can't use DispatchQueue.main.asyncAfter because the value I am updating is an inout parameter and it makes the compiler unhappy:
Escaping closure captures 'inout' parameter 'grid'
So I tried Malcolm's (malhal) suggestion to use delay, but everything happens immediately with no sequential animation (the entire block of updated items animate as one)
Here's the recursive method I am calling:
static func recursiveAlgorithm(targetFill fillValue: Int, in grid: inout [[CellItem]],
at point: (x: Int, y: Int), originalFill: Int? = nil, delay: TimeInterval) -> [[CellItem]] {
/// make sure the point is on the board (or return)
guard isValidPlacement(point) else { return grid }
/// the first time this is called we don't have `originalFill`
/// so we read it from the starting point
let tick = delay + 0.2
//AnimationTimer.shared.tick()
let startValue = originalFill ?? grid[point.x][point.y].value
if grid[point.x][point.y].value == startValue {
withAnimation(.linear(duration: 0.1).delay(tick)) {
grid[point.x][point.y].value = fillValue
}
_ = recursiveAlgorithm(targetFill: fillValue, in: &grid, at: (point.x, point.y - 1), originalFill: startValue, delay: tick)
_ = recursiveAlgorithm(targetFill: fillValue, in: &grid, at: (point.x, point.y + 1), originalFill: startValue, delay: tick)
_ = recursiveAlgorithm(targetFill: fillValue, in: &grid, at: (point.x - 1, point.y), originalFill: startValue, delay: tick)
_ = recursiveAlgorithm(targetFill: fillValue, in: &grid, at: (point.x + 1, point.y), originalFill: startValue, delay: tick)
}
return grid
}
Further comments/suggestions are welcome, as I continue to wrestle with this.
As mentioned in the comments, the lowest-tech version is probably just using a DisatpchQueue.main.asyncAfter call:
func sequencingMethod(items: Binding<[SequencedCell]>) {
var wait: TimeInterval = 0.0
for cell in items {
DispatchQueue.main.asyncAfter(deadline: .now() + wait) {
withAnimation(.linear(duration: 1)) {
cell.wrappedValue = SequencedCell(value: cell.wrappedValue.value + 1)
}
}
wait += 1.0
}
}
You could use delay(_:) for that, e.g.
func sequencingMethod(items: Binding<[SequencedCell]>) {
var delayDuration = 0.0
for cell in items {
withAnimation(.linear(duration: 4).delay(delayDuration)) {
cell.wrappedValue = SequencedCell(value: cell.wrappedValue.value + 1)
}
delayDuration += 0.5
}
}

Dealing cards from a deck - MatchedGeometryEffect - Multiple Inserted Views

I'm trying to deal groups of cards from a deck, and have the cards fly from the deck to their respective spots on screen (Stanford 2021 IOS class SET game). I'm tracking the status of each card, including whether it has been dealt or not. I thought I had all the logic correct, but the animation using a matchedGeometryEffect is not working. Both in the canvas view and when running the application in the simulator, the cards just go to their final spots in the overall View, and do not animate individually.
In an earlier version, I was seeing multiple debugger messages about "Multiple inserted views in matched geometry group Pair<Int, ID> have 'isSource: true'", but those messages no longer appear. With this slimmed-down version, the cards simply fail to animate between their original positions (in the card deck) and their final positions in the VGrid.
This codes uses a modified LazyVGrid to display the cards, with a control called AspectVGrid. The primary difference between the original LazyVGrid and the AspectVGrid is that the AspectVGrid creates a LazyVGrid with a fixed aspect ratio. The AspectVGrid only controls the grid layout itself. The code for that struct is included below.
When the application starts, all the cards are available in the deck, and each card's view is assigned its source matchedGeometryEffect. The cards are all marked as undealt (in a #State Set of card IDs). When the deck is tapped (onTapGesture), the model updates either twelve or three additional cards as wasDealt, and those newly dealt cards are supposed to be animated via a "withAnimation" code block, with individual Views in the AspectVGrid as their destinations.
Any suggestions on how to resolve this would be welcome. This seems like a pretty straightforward process, but I am clearly missing something in my implementation.
Thanks in advance for any ideas on this. I've included the model, View Model, and Views below.
Model
import Foundation
import SwiftUI
struct dataModel {
struct Card: Identifiable {
var wasDealt: Bool
var wasDiscarded: Bool
var dealDelay: Double
let id: Int
}
private(set) var firstCardWasDealt: Bool = false
private(set) var cards: Array<Card>
var cardsDealt: Array<Card> {
get {cards.filter({ card in card.wasDealt})}
}
private var numberOfCardsInPlay: Int {
get { cardsDealt.count }
}
var cardsDisplayed: Array<Card> {
get {cardsDealt.filter({ card in !card.wasDiscarded })}
}
var cardsInDeck: Array<Card> {
get {cards.filter({ card in !card.wasDealt })}
}
init() {
cards = []
newGame()
}
// Divides the total time to deal the cards by the number of cards to deal,
// and provides the applicable delay to the nth (index) card in the group of
// cards to be dealt.
private func calcDelay(numCards: Int, index: Int, totalDelay: Double) -> Double {
return Double(index) * (totalDelay / Double(numCards))
}
// If no cards have been dealt, deal twelve cards. Otherwise, deal three cards.
// When dealing the cards, apply the appropriate delay to each card being dealt.
// If all cards are already in play, don't deal any more cards.
mutating func dealMoreCards() {
if !firstCardWasDealt {
for index in (0...11) {
cards[index].dealDelay = calcDelay(numCards: 12, index: index, totalDelay: CardConstants.total12CardDealDuration)
cards[index].wasDealt = true
}
firstCardWasDealt = true
} else {
if numberOfCardsInPlay < cards.count {
let startIndex = numberOfCardsInPlay
for index in (startIndex...(startIndex + 2)) {
cards[index].dealDelay = calcDelay(numCards: 3, index: index, totalDelay: CardConstants.total3CardDealDuration)
cards[index].wasDealt = true
}
}
}
}
mutating func newGame() {
firstCardWasDealt = false
cards = []
for index in (0...80) {
cards.append(Card(wasDealt: false, wasDiscarded: false, dealDelay: 0, id: index))
}
}
}
struct CardConstants {
static let color = Color.red
static let aspectRatio: CGFloat = 2/3
static let dealAnimationDuration: Double = 0.2 // 0.5 - This value controls how long it takes to animate each card
static let total12CardDealDuration: Double = 3.0 // this controls how long it takes to deal twelve cards (in seconds)
static let total3CardDealDuration: Double = 0.75 // this controls how long it takes to deal three cards (in seconds)
}
View Model
import SwiftUI
class ViewModel: ObservableObject {
#Published private var model: dataModel
init() {
model = dataModel()
}
var cardsDealt: Array<dataModel.Card> {
model.cardsDealt
}
var cardsDisplayed: Array<dataModel.Card> {
model.cardsDisplayed
}
var cardsInDeck: Array<dataModel.Card> {
model.cardsInDeck
}
func choose(_ card: dataModel.Card) {
// do something with the chosen card
}
func dealMoreCards() {
model.dealMoreCards()
}
}
View
import SwiftUI
struct exampleView: View {
#ObservedObject var example: ViewModel
#Namespace private var dealingNamespace
#State private var dealt = Set<Int>()
private func deal(_ card: dataModel.Card) {
dealt.insert(card.id)
}
private func isUnDealt(_ card: dataModel.Card) -> Bool {
!dealt.contains(card.id)
}
var body: some View {
VStack {
Text("Example - \(example.cardsDisplayed.count) cards Displayed")
AspectVGrid(items: example.cardsDisplayed, aspectRatio: 2/3, content: { card in CardView(card: card)
.padding(4)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .opacity))
.onTapGesture {
example.choose(card)
}
})
HStack {
deckBody
Spacer()
}
}
}
var deckBody: some View {
ZStack {
ForEach(example.cardsInDeck) {
card in CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity))
}
}
.frame(width: 60, height: 90)
.foregroundColor(.cyan)
.onTapGesture {
example.dealMoreCards() // Make the next group of cards available (12 or 3)
// deal cards
for card in example.cardsDealt {
if isUnDealt(card) {
withAnimation(Animation.easeInOut(duration: CardConstants.dealAnimationDuration).delay(card.dealDelay)) {
deal(card)
}
}
}
}
}
}
struct CardView: View {
let card: dataModel.Card
var body: some View {
VStack {
ZStack {
let shape = RoundedRectangle(cornerRadius: 5)
if card.wasDealt {
shape.fill().foregroundColor(.white)
} else {
shape.fill().foregroundColor(.cyan)
}
shape.strokeBorder(lineWidth: 3)
Text("Card: \(card.id)")
.padding(4)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let example = ViewModel()
exampleView(example: example)
}
}
AspectVGrid
//
// AspectVGrid.swift
//
// Created by CS193p Instructor on 4/14/21.
// Copyright Stanford University 2021
//
import SwiftUI
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
}
Spacer(minLength: 0)
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
var columnCount = 1
var rowCount = itemCount
repeat {
let itemWidth = size.width / CGFloat(columnCount)
let itemHeight = itemWidth / itemAspectRatio
if CGFloat(rowCount) * itemHeight < size.height {
break
}
columnCount += 1
rowCount = (itemCount + (columnCount - 1)) / columnCount
} while columnCount < itemCount
if columnCount > itemCount {
columnCount = itemCount
}
return floor(size.width / CGFloat(columnCount))
}
}
MatchedGeometryEffectApp
import SwiftUI
#main
struct MatchedGeometryEffectApp: App {
private let example = ViewModel()
var body: some Scene {
WindowGroup {
exampleView(example: example)
}
}
}
So, the problem you have been having with the matched geometry seems to be that AspectVGrid, which you were given as part of the assignment, interferes with the matched geometry animation. I suspect it has to do with the resizing mechanism. Removing that gives you a matched geometry that does what one would expect.
Here is a working example of the matched geometry in your ExampleView. I also rewrote CardView to make it simpler.
struct ExampleView: View {
#ObservedObject var example: ViewModel
#Namespace private var dealingNamespace
#State private var dealt = Set<Int>()
private func deal(_ card: DataModel.Card) {
dealt.insert(card.id)
}
private func isUnDealt(_ card: DataModel.Card) -> Bool {
!dealt.contains(card.id)
}
let columns = [
GridItem(.adaptive(minimum: 80))
]
var body: some View {
VStack {
VStack {
Text("Example - \(example.cardsDisplayed.count) cards Displayed")
LazyVGrid(columns: columns, spacing: 10) {
ForEach(example.cardsDisplayed) { card in
CardView(card: card)
.aspectRatio(2/3, contentMode: .fit)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
.onTapGesture {
example.choose(card)
}
.padding(4)
}
}
Spacer()
HStack {
deckBody
Spacer()
}
}
}
}
var deckBody: some View {
ZStack {
ForEach(example.cardsInDeck) { card in
CardView(card: card)
.matchedGeometryEffect(id: card.id, in: dealingNamespace)
}
}
.frame(width: 60, height: 90)
.foregroundColor(.cyan)
.onTapGesture {
withAnimation {
example.dealMoreCards() // Make the next group of cards available (12 or 3)
// deal cards
for card in example.cardsDealt {
if isUnDealt(card) {
deal(card)
}
}
}
}
}
}
struct CardView: View {
let card: DataModel.Card
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 5)
.fill()
.foregroundColor(card.wasDealt ? .white : .cyan)
RoundedRectangle(cornerRadius: 5)
.strokeBorder(lineWidth: 3)
Text("Card: \(card.id)")
.padding(4)
}
}
}
A quick note about naming conventions. Types and structs should all be capitalized, so ExampleView and DataModel would be convention, and I changed the code to reflect that. You will need to fix yours when you incorporate this code.
Also note that this view isn't perfect. Eventually, it will push the deck of cards off the bottom of the screen, but that can be handled.
Your attempt to make the cards fly one at a time doesn't work. First, the withAnimation has to capture example.dealMoreCards() and trying to implement a .delay in that situation doesn't work. It can be implemented with a DispatchQueue.main.asyncAfter(deadline:), but you would have to rework how you pass the timing as the individual cards won't be available yet.
Lastly, the transitions do nothing in this situation.

How to create a dynamic calculation based off more than 1 State

I am brand new to Swift (and coding in general). I am working on an app that will output a calculation based off the tracking of two states. The two states are brewModel and waterAmount. I am able to successfully create a function that will return one calculation based on the two states. However, now I am trying to create a Picker that will toggle the calculation between two measurements - grams and tablespoons. This is where I am having trouble.
I tried to write a series of conditionals in different ways such as if and else if as well as switch cases, but it doesn't work. When I build the simulator, Xcode will just think for a long time until I stop it. Sometimes I get error messages after I manually stop it and sometimes I don't. Today I got "Command CompileSwiftSources failed with a nonzero exit code."
Does anyone have any suggestions?
Also, I apologize if my code is messy, I have a bunch of things commented out that I am playing with. The func computeGrinds does work but just for the one calculation. Thank you!
import SwiftUI
struct Water: View {
// #EnvironmentObject var favorites: Favorites
#State var animationInProgress = true
#State var brewModel: BrewModel
#State var waterAmount: Int = 1
#State var grindsSelection = "tbsp"
var grindOptions = ["tbsp", "grams"]
// var resultGrindCalc: Double {
//
// var value = Double(0)
// }
// switch grindsSelection {
// case "tbsp" || brewModel.frenchPress:
// value = Double(waterAmount) * 2.5
//
// }
//
// func computeGrinds () -> Double {
// switch brewModel {
// case .frenchPress, .chemex:
// return (2.5 * Double(waterAmount))
// case .drip :
// return Double(2 * Double(waterAmount))
// case .mokaPot:
// return Double(1 * Double(waterAmount))
// case .aeroPress:
// return Double(1.6 * Double(waterAmount))
// // default:
// // return(1 * Double(waterAmount))
// }
// }
var body: some View {
VStack (spacing: 5) {
Spacer()
HStack {
// Text("").padding(20)
Text("How many cups do you want to brew?")
Picker("", selection: $waterAmount) {
ForEach(1...15, id: \.self){
Text("\($0)")
}
}
// Spacer()
}.padding()
.overlay (
RoundedRectangle(cornerRadius: 16)
.stroke(Color("Custom Color"), lineWidth: 8)
)
// gif/image conditionals
if (brewModel == .frenchPress) {
LottieView(name: "frenchpress", loopMode: .loop)
} else if brewModel == .chemex {
LottieView(name: "pourover", loopMode: .loop)
} else if brewModel == .aeroPress {
LottieView(name: "aeropress", loopMode: .loop)
} else if brewModel == .mokaPot {
LottieView(name: "mokapot", loopMode: .loop)
} else if brewModel == .drip {
Image("Drip")
.resizable()
.scaledToFit()
}
// I would have more conditionals but testing with just these two for now
var testingCalcCond = Double
if (brewModel == .frenchPress)||(grindsSelection=="tbsp") {
testingCalcCond = (2.5 * Double(waterAmount))
} else if (brewModel == .frenchPress)||(grindsSelection=="grams") {
testingCalcCond = (16 * Double(waterAmount))
}
let formatted = String(format: "%.2f", testingCalcCond)
// let formatted = String(format: "%.2f", computeGrinds())
HStack {
Text("**\(formatted)**")
Picker("Select Grinds Units: ", selection: $grindsSelection, content: {
ForEach(grindOptions, id: \.self) {
Text($0)
}
}).onChange(of: grindsSelection) { _ in computeGrinds() }
Text("of coffee grinds needed")
}
.padding()
.overlay (
RoundedRectangle(cornerRadius: 16)
.stroke(Color("Custom Color"), lineWidth: 8)
)
}
Spacer()
}
}
struct Water_Previews: PreviewProvider {
static var previews: some View {
Water(brewModel: .drip)
}
}
}
*I'm using Xcode 13.2.1
*I'm using swiftUI
There are 2 aspects you want to think over:
1. How to update the result value based on the inputs.
Your result value is based on two inputs: brewModel and waterAmount. Both are #State vars and changed by a picker.
I changed your computeGrinds func to a computed property, because this will be automatically called when one of the two base values changes. Then there is no need for .onchange anymore, you can just use the var value – it will always be up to date.
2. recalculating from tbsp to grams.
This is more of a math thing: As I understand, for .frenchPress you need either 2.5 tbsp – or 16 grams per cup. So 1 tbsp = 16 / 2.5 = 6.4 grams. Once you know that you just have to go through the switch case once, and use the unitValue to recalculate. I integrated that too ;)
Here is my simplified code:
enum BrewModel {
case frenchPress
case chemex
case drip
case mokaPot
case aeroPress
}
struct ContentView: View {
#State var animationInProgress = true
#State var brewModel: BrewModel = .frenchPress
#State var waterAmount: Int = 1
#State var grindsSelection = "tbsp"
let grindOptions = ["tbsp", "grams"]
// computed var instead of func, does the same
var computeGrinds: Double {
// transforms tbsp = 1 to grams (= 6.4 ?)
var unitValue: Double = 1.0
if grindsSelection == "grams" {
unitValue = 6.4
}
switch brewModel {
case .frenchPress, .chemex:
return (2.5 * unitValue * Double(waterAmount))
case .drip :
return Double(2 * unitValue * Double(waterAmount))
case .mokaPot:
return Double(1 * unitValue * Double(waterAmount))
case .aeroPress:
return Double(1.6 * unitValue * Double(waterAmount))
}
}
var body: some View {
VStack (spacing: 5) {
HStack {
Text("How many cups do you want to brew?")
Picker("", selection: $waterAmount) {
ForEach(1...15, id: \.self){
Text("\($0)")
}
}
}
.padding()
.overlay (
RoundedRectangle(cornerRadius: 16)
.stroke(Color.brown, lineWidth: 8)
)
.padding(.bottom)
let formatted = String(format: "%.2f", computeGrinds)
HStack {
Text("**\(formatted)**")
Picker("Select Grinds Units: ", selection: $grindsSelection, content: {
ForEach(grindOptions, id: \.self) {
Text($0)
}
})
Text("of coffee grinds needed")
}
.padding()
.overlay (
RoundedRectangle(cornerRadius: 16)
.stroke(Color.brown, lineWidth: 8)
)
}
}
}

SwiftUI MVVM #Environment Breaks

I'm trying to do a fairly simple View with MVVM to be a good ViewModel citizen. However,
the code breaks while accessing the #Enviromnent Core Data in the ViewModel. I created
two functions in the ViewModel. One accesses Core Data through the #Environment and one
accesses Core Data with the old style - get a reference to AppDelegate and do my own
thing. The OldSchool method works. Comment 2 below. The #Environment does not - it breaks
at the line indicated below with an error that is not helpful for me. Comment 1.
(Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0))
Now if I take the same #Environment code and put it directly into the view it Works.
And if I call the same line that breaks the MVVM in a Text in the View I get the
correct response. Comment 2
This is the view:
struct UserUtilities: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: DermPhoto.getAllDermPhotos()) var dermPhotos: FetchedResults<DermPhoto>
#State private var reminderInterval = 1
#State private var enableReminders = true
#State private var daysSincePhoto: Int = 0
#ObservedObject var userUtilitiesVM = UserUtilitiesViewModel()
var body: some View {
NavigationView {
VStack {
Group { //group 1
Toggle(isOn: $enableReminders) {
Text("Enable Reminders")
}
.padding(EdgeInsets(top: 50, leading: 50, bottom: 0, trailing: 50))
Text("Reminders are" + (enableReminders == true ? " On" : " Off"))
Spacer()
//Comment 3 - this always works
Text("String interpolation of self.dermPhotos.count")
Text("\(self.dermPhotos.count)")
} //group 1
Group { //group 2
Text("It has been " + "\(self.daysSincePhoto) " + (daysSincePhoto == 1 ? "day" : "days") + " since a photo was added.")
.padding(EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50))
//options for the sentence above
//\(self.userUtilitiesVM.getTheDateInterval())
//\(self.userUtilitiesVM.getFromEnvironment())
//\(self.userUtilitiesVM.dateInterval)
Spacer()
Stepper("Reminder Interval", value: $reminderInterval, in: 1 ... 30)
.padding(EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50))
Text("Reminder Interval is: \(reminderInterval)" + (reminderInterval == 1 ? " day" : " days"))
Spacer()
}//group 2
}
.navigationBarTitle("Reminder Days", displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
print("getting the date interval from the nav button")
//self.daysSincePhoto = self.userUtilitiesVM.getOldSchool()
self.daysSincePhoto = self.userUtilitiesVM.getFromEnvironment()
//self.daysSincePhoto = self.getFromEnvironment()
} , label: { Text("Fetch")
}))
}
}
//this always works
func getFromEnvironment() -> Int {
let numberOfRecords = self.dermPhotos.count
if numberOfRecords > 0 {
let now = Date()
let lastDate = self.dermPhotos.last?.addDate
//ok to bang - addDate is always added to core data
let dateInterval = DateInterval(start: lastDate!, end: now)
let days = Int(dateInterval.duration) / (24 * 3600)
self.daysSincePhoto = days
return days
}
return 0
}
}
And this is the ViewModel:
class UserUtilitiesViewModel: ObservableObject {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(fetchRequest: DermPhoto.getAllDermPhotos()) var dermPhotos: FetchedResults<DermPhoto>
#Published var dateInterval: Int = 50
//Comment 2 this always works
func getOldSchool() -> Int {
let kAppDelegate = UIApplication.shared.delegate as! AppDelegate
let context = kAppDelegate.persistentContainer.viewContext
var resultsDermPhotos : [DermPhoto] = []
let fetchRequest: NSFetchRequest<DermPhoto> = DermPhoto.fetchRequest() as! NSFetchRequest<DermPhoto>
let sortDescriptor = NSSortDescriptor(key: "addDate", ascending: true)
fetchRequest.sortDescriptors = [sortDescriptor]
do {
resultsDermPhotos = try context.fetch(fetchRequest)
} catch {
print("the fetchRequest error is \(error.localizedDescription)")
}
let numberOfRecords = resultsDermPhotos.count
if numberOfRecords > 0 {
let now = Date()
let lastDate = resultsDermPhotos.last?.addDate
//ok to bang this since addDate is always added to core data
let di = DateInterval(start: lastDate!, end: now)
let days = Int(di.duration) / (24 * 3600)
dateInterval = days
return days
}
return 0
}
//this never works
func getFromEnvironment() -> Int {
//Comment 1 - this breaks
let numberOfRecords = self.dermPhotos.count
if numberOfRecords > 0 {
let now = Date()
let lastDate = self.dermPhotos.last?.addDate
//ok to bang this since addDate is always added to core data
let di = DateInterval(start: lastDate!, end: now)
let days = Int(di.duration) / (24 * 3600)
dateInterval = days
return days
}
return 0
}
}
Clearly I can use old school or abandon the ViewModel idea but I would like to know how to fix this. Any guidance would be appreciated. Xcode 11.3 (11C29)
I don't think you can access #Environment from outside View hierarchy.
Your view model does not conform to view. But it won't be view model once it conforms to it.
=> you should abandon view model idea

Resources