how to make a 2048 game via swiftUI [closed] - animation

Closed. This question needs to be more focused. It is not currently accepting answers.
Want to improve this question? Update the question so it focuses on one problem only by editing this post.
Closed 2 years ago.
Improve this question
I try to make a 2048 game via SwiftUI, my game logics are mostly correct, but the transitions/Animations were wrong, I don't know where are the problems and how to fix them!
The game now looks like this :
the game acting gif
I have several questions:
It looks like when the tile moves to left or top, the transition was hidden behind the gray background view, I try to add ZIndex to the tile and background, but it's not working.
How to make a sequenced transition, it should first moved from the origin cell, then scale itself from larger to normal
The merged transition not working every time as I expected, in fact it only happens a few times
My view code looks like this:
struct GameView: View {
#ObservedObject var viewModel:GameViewModel
var body: some View {
let tap = DragGesture().onEnded({value in self.move(by: value)})
return GeometryReader{geo in
ZStack{
RoundedRectangle(cornerRadius: 10.0).fill(Color(#colorLiteral(red: 0.719702065, green: 0.6819230318, blue: 0.6252140403, alpha: 1)))
VStack{
ForEach(0..<self.viewModel.gridSize){rowIndex in
HStack {
ForEach(self.viewModel.tiles[rowIndex]){tile in
TileView(tile:tile)
}
}
}
}
.padding()
}
.gesture(tap)
.frame(width: geo.size.width, height: geo.size.width, alignment: .center)
}
}
func move(by value:DragGesture.Value){
withAnimation(Animation.easeInOut.speed(2)){
if abs(value.translation.height) > abs(value.translation.width){
if value.translation.height > 30 {
self.viewModel.move(by:GameModel.Direction.down)
}else if value.translation.height < -30{
self.viewModel.move(by:GameModel.Direction.up)
}
}else{
if value.translation.width > 30 {
self.viewModel.move(by:GameModel.Direction.right)
}else if value.translation.width < -30{
self.viewModel.move(by:GameModel.Direction.left)
}
}
}
}
}
struct TileView: View {
var tile:GameModel.Tile
var body: some View {
GeometryReader { geometry in
self.body(for: geometry.size)
}
}
#ViewBuilder
private func body(for size:CGSize) -> some View {
ZStack{
RoundedRectangle(cornerRadius: 10.0).fill(Color(#colorLiteral(red: 0.794301331, green: 0.7563138604, blue: 0.7084676027, alpha: 1)))
if(tile.value != 0){
ZStack{
RoundedRectangle(cornerRadius: 10.0).fill(tile.bgColor)
Text(String(tile.value)).font(Font.system(size: fontSize(for: size,in: tile.value))).foregroundColor(tile.fontColor)
}
.transition(transition(for: size))
.zIndex(100)
}
}.zIndex(98)
}
private let cornerRadius:CGFloat = 10.0
private let edgeLineWidth:CGFloat = 3
private func fontSize(for size: CGSize, in value:Int) -> CGFloat{
size.width * 0.7 / CGFloat(String(value).count)
}
private func transition(for size:CGSize) -> AnyTransition{
var transition:AnyTransition
if let mergedFrom = tile.mergedFrom{
let perviousTile = mergedFrom[0]
let offset = CGSize(width: CGFloat(perviousTile.x - tile.x )*size.width, height: CGFloat(perviousTile.y - tile.y )*size.height)
transition = AnyTransition.offset(offset).combined(with: AnyTransition.scale(scale: 2))
}else{
if let previousPosition = tile.previousPosition {
let offset = CGSize(width: CGFloat(previousPosition.x - tile.x )*size.width, height: CGFloat(previousPosition.y - tile.y )*size.height)
transition = AnyTransition.offset(offset)
}else{
transition = AnyTransition.scale
}
}
return AnyTransition.asymmetric(insertion: transition.animation(.easeInOut), removal: .identity)
}
}
and the model code:
struct GameModel {
private(set) var tiles: Array<Array<Tile>>
private(set) var gridSize:Int
init(gridSize:Int){
self.gridSize = gridSize
tiles = Array<Array<Tile>>()
for i in 0..<gridSize{
var row = Array<Tile>()
for j in 0..<gridSize{
row.append(Tile(x: j, y: i))
}
tiles.append(row)
}
generateTile()
generateTile()
}
private mutating func generateTile(){
var emptyPositionArr:Array<(x:Int,y:Int)> = []
for rowIndex in 0..<gridSize{
for colIndex in 0..<gridSize{
if(tiles[rowIndex][colIndex].value == 0){
emptyPositionArr.append((x:colIndex,y:rowIndex))
}
}
}
if let randomPos = emptyPositionArr.randomElement() {
let randomValue = Bool.random() ? 2 : 4
tiles[randomPos.y][randomPos.x].value = randomValue
}else{
print("No remaining spaces!")
}
}
mutating func prepareTiles(){
for y in 0..<gridSize{
for x in 0..<gridSize{
if(tiles[y][x].value != 0){
tiles[y][x].mergedFrom = nil
// tiles[y][x].savePosition()
}
}
}
}
mutating func move(by direction:Direction){
let vector:(x:Int,y:Int) = getVector(by: direction)
var moved = false
prepareTiles()
var col:Array<Int> = []
var row:Array<Int> = []
for i in 0...3{
col.append(i)
row.append(i)
}
if(vector.x == 1){
col = col.reversed()
}
if(vector.y == 1){
row = row.reversed()
}
for y in row{
for x in col{
let cell = Cell(x: x, y: y)
var newCell:Cell
let tile = tiles[y][x]
if(tile.value != 0){
let positions = findFarthestPosition(cell: cell, vector: vector)
if let next = cellCotent(at: positions.next), next.value == tile.value, next.mergedFrom == nil{
let merged = Tile(x: next.x, y: next.y, value: tile.value * 2, mergedFrom: [tile,next])
insertTile(tile: merged)
removeTile(tile: tile)
newCell = Cell(x: next.x, y: next.y)
}else{
removeTile(tile: tile)
insertTile(tile: Tile(x: positions.farthest.x, y: positions.farthest.y, value: tile.value,previousPosition: cell))
newCell = Cell(x: positions.farthest.x, y: positions.farthest.y)
}
if(newCell.x != cell.x || newCell.y != cell.y){
moved = true
}
}
}
}
if(moved){
generateTile()
}
}
func findFarthestPosition(cell: Cell,vector:(x:Int,y:Int)) -> (farthest:Cell,next:Cell){
var previous:Cell
var currentcell = cell
repeat{
previous = currentcell
currentcell = Cell(x: previous.x + vector.x, y: previous.y + vector.y)
}while(withinBounds(cell: currentcell) && cellAvailable(cell: currentcell) )
return (previous,currentcell)
}
func getVector(by direction:Direction) -> (x:Int,y:Int){
var x = 0;
var y = 0;
switch direction {
case .down:
y = 1
case .up:
y = -1
case .left:
x = -1
case .right:
x = 1
}
return (x:x,y:y)
}
//MARK: - Cell
struct Cell {
var x:Int
var y:Int
}
func cellCotent(at cell:Cell) -> Tile? {
if(withinBounds(cell: cell)){
return tiles[cell.y][cell.x]
}else{
return nil
}
}
func cellAvailable (cell:Cell) -> Bool {
if(withinBounds(cell: cell)){
return tiles[cell.y][cell.x].value == 0
}else{
return false
}
}
func withinBounds(cell:Cell) -> Bool {
return cell.x >= 0 && cell.x < self.gridSize && cell.y >= 0 && cell.y < gridSize
}
mutating func insertTile(tile:Tile){
tiles[tile.y][tile.x] = tile
}
mutating func removeTile(tile:Tile){
tiles[tile.y][tile.x].value = 0
tiles[tile.y][tile.x].mergedFrom = nil
tiles[tile.y][tile.x].previousPosition = nil
}
mutating func moveTile(tile:Tile,cell:Cell){
tiles[tile.y][tile.x].value = 0
tiles[tile.y][tile.x].mergedFrom = nil
tiles[cell.y][cell.x] = tile
}
//MARK: - Tile
struct Tile: Identifiable{
var x:Int
var y:Int
var value:Int = 0
var mergedFrom:Array<Tile>?
var previousPosition:Cell?
var bgColor:Color{
get{
if(value > 2048){
return Color(.black)
}else{
return GameModel.ColorMap[value]!}
}
}
var fontColor:Color{
get{
if(value <= 4){
return Color(.black)
}else{
return Color(.white)
}
}
}
var id: Int{
get{
(y*10) + x
}
}
mutating func savePosition(){
self.previousPosition = Cell(x: x, y: y)
}
}
static let ColorMap = [
0:Color(#colorLiteral(red: 0.8036968112, green: 0.7560353875, blue: 0.7039339542, alpha: 1)),
2: Color(#colorLiteral(red: 0.9316522479, green: 0.8934505582, blue: 0.8544340134, alpha: 1)),
4: Color(#colorLiteral(red: 0.9296537042, green: 0.8780228496, blue: 0.7861451507, alpha: 1)),
8: Color(#colorLiteral(red: 0.9504186511, green: 0.6943461895, blue: 0.4723204374, alpha: 1)),
16: Color(#colorLiteral(red: 0.9621869922, green: 0.6018956304, blue: 0.3936881721, alpha: 1)),
32:Color(#colorLiteral(red: 0.9640850425, green: 0.49890697, blue: 0.3777080476, alpha: 1)),
64: Color(#colorLiteral(red: 0.9669782519, green: 0.406899184, blue: 0.2450104952, alpha: 1)),
128: Color(#colorLiteral(red: 0.9315031767, green: 0.8115276694, blue: 0.4460085034, alpha: 1)),
256: Color(#colorLiteral(red: 0.9288312197, green: 0.7997121811, blue: 0.3823960423, alpha: 1)),
512: Color(#colorLiteral(red: 0.9315162301, green: 0.783490479, blue: 0.3152971864, alpha: 1)),
1024: Color(#colorLiteral(red: 0.9308142066, green: 0.7592952847, blue: 0.179728806, alpha: 1)),
2048: Color(#colorLiteral(red: 0.9308142066, green: 0.7592952847, blue: 0.179728806, alpha: 1)),
]
enum Direction {
case up
case down
case left
case right
}
}
viewmodel:
class GameViewModel: ObservableObject {
#Published private(set) var model: GameModel = GameViewModel.createGame()
private static func createGame () -> GameModel {
return GameModel(gridSize: 4)
}
//MARK: - Access to the model
var tiles:Array<Array<GameModel.Tile>> {
model.tiles
}
var gridSize:Int {
model.gridSize
}
//MARK: - Intent(s)
func move(by direction:GameModel.Direction){
model.move(by: direction)
}
}
My source code was uploaded at github, please someone help me

That happens because the tiles are being moved to a position where their Z position is lower than the rows "in the way"
You can separate the background from the tile, so you be sure that the tiles are always on top:
var body: some View {
let tap = DragGesture().onEnded({value in self.move(by: value)})
return GeometryReader{geo in
ZStack{
RoundedRectangle(cornerRadius: 10.0).fill(Color(#colorLiteral(red: 0.719702065, green: 0.6819230318, blue: 0.6252140403, alpha: 1)))
VStack{
ForEach(0..<self.viewModel.gridSize){ rowIndex in
HStack {
ForEach(0..<self.viewModel.gridSize){ columnIndex in
RoundedRectangle(cornerRadius: 10.0).fill(Color(#colorLiteral(red: 0.794301331, green: 0.7563138604, blue: 0.7084676027, alpha: 1))).transition(.identity)
}
}
}
}
.padding()
VStack{
ForEach(0..<self.viewModel.gridSize){rowIndex in
HStack {
ForEach(self.viewModel.tiles[rowIndex]){tile in
TileView(tile:tile)
}
}
}
}
.padding()
}
.gesture(tap)
.frame(width: geo.size.width, height: geo.size.width, alignment: .center)
}
}

Related

SwiftUI Animation - Issue with Tabview

I am having an issue with animations in a tabview. I have a tabview with 2 views.
The first view has a shape with an animation. The second view is a simple text.
When I launch the application, View1 appears and the animation is correct. When I swipe to View2 and come back to View1, the animation no longer appear as intended and is somewhat random. Anyone might know what the issue might be ? Thank you.
ContentView
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
View1()
View2()
} //: TAB
.tabViewStyle(PageTabViewStyle())
.padding(.vertical, 20)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
View1
import SwiftUI
struct FollowEffect: GeometryEffect {
var pct: CGFloat = 0
let path: Path
var rotate = true
var animatableData: CGFloat {
get { return pct }
set { pct = newValue }
}
func effectValue(size: CGSize) -> ProjectionTransform {
if !rotate {
let pt = percentPoint(pct)
return ProjectionTransform(CGAffineTransform(translationX: pt.x, y: pt.y))
} else {
// Calculate rotation angle, by calculating an imaginary line between two points
// in the path: the current position (1) and a point very close behind in the path (2).
let pt1 = percentPoint(pct)
let pt2 = percentPoint(pct - 0.01)
let a = pt2.x - pt1.x
let b = pt2.y - pt1.y
let angle = a < 0 ? atan(Double(b / a)) : atan(Double(b / a)) - Double.pi
let transform = CGAffineTransform(translationX: pt1.x, y: pt1.y).rotated(by: CGFloat(angle))
return ProjectionTransform(transform)
}
}
func percentPoint(_ percent: CGFloat) -> CGPoint {
let pct = percent > 1 ? 0 : (percent < 0 ? 1 : percent)
let f = pct > 0.999 ? CGFloat(1-0.001) : pct
let t = pct > 0.999 ? CGFloat(1) : pct + 0.001
let tp = path.trimmedPath(from: f, to: t)
return CGPoint(x: tp.boundingRect.midX, y: tp.boundingRect.midY)
}
}
struct Solar2Grid: Shape {
func path(in rect: CGRect) -> Path {
return Solar2Grid.createArcPath(in: rect)
}
static func createArcPath(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: rect.height - 20))
path.addArc(center: CGPoint(x: rect.width - 20, y: rect.height - 20), radius: CGFloat(20), startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
path.addLine(to: CGPoint(x: 0, y: rect.height))
return path
}
}
struct AnimRecView: View {
#State var flag: Bool = false
var body: some View {
ZStack {
Solar2Grid()
.stroke(Color.purple, style: StrokeStyle( lineWidth: 2, dash: [3]))
Circle()
.foregroundColor(Color.red)
.blur(radius: 3.0)
.frame(width: 8, height: 8).offset(x: -40, y: -40)
.modifier(FollowEffect(pct: self.flag ? 1 :0, path: Solar2Grid.createArcPath(in: CGRect(x: 0, y: 0, width: 80, height: 80)), rotate: false))
.onAppear {
withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) {
self.flag.toggle()
}
}
}
}
}
struct View1: View {
#State var flag: Bool = false
var body: some View {
VStack() {
Text("View1")
Spacer()
HStack() {
AnimRecView()
}
.frame(width: 80, height: 80, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
Spacer()
}
.frame(minWidth: /*#START_MENU_TOKEN#*/0/*#END_MENU_TOKEN#*/, maxWidth: /*#START_MENU_TOKEN#*/.infinity/*#END_MENU_TOKEN#*/, minHeight: /*#START_MENU_TOKEN#*/0/*#END_MENU_TOKEN#*/, maxHeight: /*#START_MENU_TOKEN#*/.infinity/*#END_MENU_TOKEN#*/, alignment: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/)
.background(LinearGradient(gradient: Gradient(colors: [Color.blue, Color.black]), startPoint: .top, endPoint: .bottom))
.cornerRadius(20)
.padding(.horizontal, 20)
}
}
struct View1_Previews: PreviewProvider {
static var previews: some View {
View1()
}
}
View2
import SwiftUI
struct View2: View {
var body: some View {
Text("View2")
}
}
struct View2_Previews: PreviewProvider {
static var previews: some View {
View2()
}
}
The problem is that .onAppear() is only called once, so the next time the view is shown, the animation doesn't know what to do. The fix is to put an explicit animation on the Circle() itself. Then, when the view comes back on screen, it has the appropriate animation. Like this:
struct AnimRecView: View {
#State var flag: Bool = false
var body: some View {
ZStack {
Solar2Grid()
.stroke(Color.purple, style: StrokeStyle( lineWidth: 2, dash: [3]))
Circle()
.foregroundColor(Color.red)
.blur(radius: 3.0)
.frame(width: 8, height: 8).offset(x: -40, y: -40)
.modifier(FollowEffect(pct: self.flag ? 1 : 0, path: Solar2Grid.createArcPath(in: CGRect(x: 0, y: 0, width: 80, height: 80)), rotate: false))
// Put the explicit animation here
.animation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false), value: flag)
.onAppear {
self.flag = true
}
}
}
}

SwiftUI Animation Circle with Colors

My problem is simple I think but I can't figure how solve it.
I've this :
struct ArcSelectionView: View {
#Binding var isShowing: Bool
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State var colors = [Color.blue, Color.red, Color.green, Color.yellow]
var body: some View {
ZStack {
ForEach(1 ..< 5, id: \.self) { item in
Circle()
.trim(from: self.isShowing ? CGFloat((Double(item) * 0.25) - 0.25) : CGFloat(Double(item) * 0.25),
to: CGFloat(Double(item) * 0.25))
.stroke(self.colors[item - 1], lineWidth: 50)
.frame(width: 300, height: 300)
.onTapGesture {
if colors[item - 1] == colorToPress {
score += 1
}
isShowing.toggle()
colorToPress = colors.randomElement() ?? Color.offWhite
colors.shuffle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.isShowing.toggle()
}
}
}
}
.opacity(self.isShowing ? 1 : 0)
.rotationEffect(.degrees(self.isShowing ? 0 : 180))
.animation(.linear(duration: 0.35))
}
}
If I didn't shuffle colors in the .onTapGesture, everything is ok. But If I do, I've a strange plain Circle that appears in the middle and disappear after. It's ugly. Ugly Circle
Thank you for your help !
The issue is with the animation of the Circles. The better solution is to use arc shapes. Here is a working solution:
struct ArcSelectionView: View {
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State private var colors = [Color.blue, Color.red, Color.green, Color.yellow]
#State private var pct: CGFloat = 0.25
#State private var originalPCT: CGFloat = 0.25
let duration: Double = 0.35
var body: some View {
ZStack {
CircleView(wedge: originalPCT)
// I am not sure why, but at there is a difference of 10 in the sizes of the
// circle and the modifier. This corrects for it so the touch is accurate.
.frame(width: 310, height: 310)
PercentageArc(Color.clear, colors: colors, pct: pct) {
// With this solution you must have the callback sent to
// the main thread. This was unnecessary with AnimatbleModifier.
DispatchQueue.main.async {
pct = originalPCT
}
}
.animation(.linear(duration: duration), value: pct)
.frame(width: 300, height: 300)
// This forces the view to ignore taps.
.allowsHitTesting(false)
}
.onAppear {
pct = 1.0 / CGFloat(colors.count)
originalPCT = pct
}
}
func CircleView(wedge: CGFloat) -> some View {
ZStack {
// Array(zip()) is a cleaner and safe way of using indices AND you
// have the original object to use as well.
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
Circle()
.trim(from: CGFloat((Double(index) * wedge)),
to: CGFloat(Double(index + 1) * wedge))
// The color of the stroke should match your background color.
// Clear won't work.
.stroke(.white, lineWidth: 50)
.onTapGesture {
if color == colorToPress {
score += 1
print("score!")
}
pct = 0
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
colorToPress = colors.randomElement() ?? .white
colors.shuffle()
}
}
}
}
}
}
struct PercentageArc<Content>: View, Animatable where Content: View {
private var content: Content
private var colors: [Color]
private var pct: CGFloat
private var target: CGFloat
private var onEnded: () -> ()
init(_ content: Content, colors: [Color], pct: CGFloat, onEnded: #escaping () -> () = {}) {
self.content = content
self.colors = colors
self.pct = pct
self.target = pct
self.onEnded = onEnded
}
var animatableData: CGFloat {
get { pct }
set { pct = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
if newValue == target {
onEnded()
}
}
}
var body: some View {
content
.overlay(
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
ArcPortionShape(pct: pct, startAngle: .degrees(1.0 / CGFloat(colors.count) * CGFloat(index) * 360.0))
.foregroundColor(color)
}
)
}
struct ArcPortionShape: InsettableShape {
let pct: CGFloat
let startAngle: Angle
var insetAmount = 0.0
init(pct: CGFloat, startAngle: Angle) {
self.pct = pct
self.startAngle = startAngle
}
var portion: CGFloat {
pct * 360.0
}
var endAngle: Angle {
.degrees(startAngle.degrees + portion)
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
radius: rect.height / 2.0 + 5.0,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
return p.strokedPath(.init(lineWidth: 50))
}
func inset(by amount: CGFloat) -> some InsettableShape {
var arc = self
arc.insetAmount += amount
return arc
}
}
}
Originally, I made this with an AnimatableModifier, but it is deprecated, and the solution using it fails if it is placed in ANY stack or NavigationView. I can see why AnimatableModifier is deprecated.
This solution draws inspiration from this answer from Asperi, for the callback idea, though the solution will not work in iOS 15.2.

SwiftUI odd animation behavior with systemImage

I was messing around with a fun animation in SwiftUI when I ran into a weird problem involving animating changes to SwiftUI's SF symbols. Basically, I want to animate a set of expanding circles that lose opacity as they get farther out. This works fine when I animate the circles using the Circle() shape, but throws a weird error when I use Image(systemName: "circle"). Namely, it throws No symbol named 'circle' found in system symbol set and I get the dreaded "purple" error in Xcode. Why does my animation work with shapes but not with SF symbols?
Animation Code with Shapes:
struct ContentView: View {
let timer = Timer.publish(every: 0.25, on: .main, in: .common).autoconnect()
#State var firstIndex: Int = 0
#State var secondIndex: Int = 10
#State var thirdIndex: Int = 20
#State var fourthIndex: Int = 30
private func changeIndex(index: Int) -> Int {
if index == 40 {
return 0
} else {
return index + 1
}
}
var body: some View {
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircle(index: firstIndex)
ExpandingCircle(index: secondIndex)
ExpandingCircle(index: thirdIndex)
ExpandingCircle(index: fourthIndex)
}
.onReceive(timer) { time in
withAnimation(.linear(duration: 0.25)) {
self.firstIndex = changeIndex(index: firstIndex)
self.secondIndex = changeIndex(index: secondIndex)
self.thirdIndex = changeIndex(index: thirdIndex)
self.fourthIndex = changeIndex(index: fourthIndex)
}
}
}
}
Where ExpandingCircle is defined as:
struct ExpandingCircle: View {
let index: Int
private func getSize() -> CGFloat {
return CGFloat(index * 2)
}
private func getOpacity() -> CGFloat {
if index == 0 || index == 40 {
return 0
} else {
return CGFloat(1 - (Double(index) * 0.025))
}
}
var body: some View {
Circle()
.strokeBorder(Color.red, lineWidth: 4)
.frame(width: getSize(), height: getSize())
.opacity(getOpacity())
}
}
To replicate the error, swap out ExpandingCircle in ContentView for ExpandingCircleImage:
struct ExpandingCircleImage: View {
let index: Int
private func getSize() -> CGFloat {
return CGFloat(index * 2)
}
private func getOpacity() -> CGFloat {
if index == 0 || index == 40 {
return 0
} else {
return CGFloat(1 - (Double(index) * 0.025))
}
}
var body: some View {
Image(systemName: "circle")
.foregroundColor(.red)
.font(.system(size: getSize()))
.opacity(getOpacity())
}
}
Your ExpandingCircleImage is choking because you can't have a system font of size 0, and you keep trying to feed 0 to your ExpandingCircleImage view. However, in addition to that, you don't need to use a timer to drive the animation. In fact, it makes the animation look weird because a timer is not exact. Next, your ExpandingCircle or ExpandingCircleImage should animate itself and be the complete effect.
The next issue you will encounter when you fix the font size = 0 issue, is that .font(.system(size:)) is not animatable as it is. You need to write an AnimatableModifier for it. That looks like this:
struct AnimatableSfSymbolFontModifier: AnimatableModifier {
var size: CGFloat
var animatableData: CGFloat {
get { size }
set { size = newValue }
}
func body(content: Content) -> some View {
content
.font(.system(size: size))
}
}
extension View {
func animateSfSymbol(size: CGFloat) -> some View {
self.modifier(AnimatableSfSymbolFontModifier(size: size))
}
}
The animatableData variable is the key. It teaches SwiftUI what to change to render the animation. In this case, we are animating the size of the font. The view extension is just a convenience so we can use . notation.
Another trick to animating a view like this is to have multiple animations that only go part of the way of the whole. In other words, if you use four circles, the first goes to 25%, the next from 25% to 50%, then 50% to 75%, lastly 75% to 100%. You also appear to have wanted the rings to fade as the expand, so I wrote that in as well. The code below will have two animating views, one made with a shape, and one with an SF Symbol.
struct ContentView: View {
var body: some View {
VStack {
Spacer()
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircle(maxSize: 100)
}
.frame(height: 100)
Spacer()
ZStack {
Circle()
.foregroundColor(.black)
.frame(width: 10, height: 10)
ExpandingCircleImage(maxSize: 100)
}
.frame(height: 100)
Spacer()
}
}
}
struct ExpandingCircle: View {
let maxSize: CGFloat
#State private var animate = false
var body: some View {
ZStack {
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.75 : 1)
.scaleEffect(animate ? 0.25 : 0)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.5 : 0.75)
.scaleEffect(animate ? 0.5 : 0.25)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0.25 : 0.5)
.scaleEffect(animate ? 0.75 : 0.5)
Circle()
.strokeBorder(Color.red, lineWidth: 8)
.opacity(animate ? 0 : 0.25)
.scaleEffect(animate ? 1 : 0.75)
}
.frame(width: maxSize, height: maxSize)
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
struct ExpandingCircleImage: View {
let maxSize: CGFloat
#State private var animate = false
var body: some View {
ZStack {
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.25) : 1)
.opacity(animate ? 0.75 : 1)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.5) : (maxSize * 0.25))
.opacity(animate ? 0.5 : 0.75)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize * 0.75) : (maxSize * 0.5))
.opacity(animate ? 0.25 : 0.5)
Image(systemName: "circle")
.animateSfSymbol(size: animate ? (maxSize) : (maxSize * 0.75))
.opacity(animate ? 0 : 0.25)
}
.foregroundColor(.red)
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
animate = true
}
}
}
}
Remember to include the AnimatableModifier in your code.

View not rendering properly when embedded in App

I have a view struct that renders ok in preview, but not when embedded in an app. The view used to display image files, and worked ok when the dice changed value. Now it has changed to drawing dynamically, it has stopped working correctly. It stays displaying the same number of dots as the first values, and misplaces the dots when it redraws.
Below is the code for the view:
import SwiftUI
struct DiceView: View {
var dice: NubbleGame.Dice
var color: Color = Color.red
let dotSizeDivisor = 5
var body: some View {
GeometryReader
{ viewSize in
let smallestSide = min(viewSize.size.width, viewSize.size.height)
let dotSize = smallestSide/CGFloat(dotSizeDivisor)
ZStack()
{
RoundedRectangle(cornerRadius: smallestSide/10)
.stroke(Color.black, lineWidth: viewSize.size.width/50)
ForEach(0..<dice.currentValue)
{ i in
ZStack()
{
Circle()
.fill(color)
.position(getPositionForDot(dotNumber: i+1, diceValue: dice.currentValue, diceSize:smallestSide))
.frame(width: dotSize, height: dotSize, alignment: .center)
Circle()
.stroke(Color.black, lineWidth: 2)
.position(getPositionForDot(dotNumber: i+1, diceValue: dice.currentValue, diceSize:smallestSide))
.frame(width: dotSize, height: dotSize, alignment: .center)
}
}
}
}
}
func getPositionForDot(dotNumber: Int, diceValue: Int, diceSize: CGFloat) -> CGPoint
{
var tempDiceValue = diceValue
var tempDotNumber = dotNumber
let zero = CGFloat(diceSize/CGFloat(dotSizeDivisor))
let offSet = CGFloat((diceSize * 0.9) / 2)
let center = CGFloat(diceSize/2)
var thisDot: CGPoint = CGPoint(x: 0, y: 0)
if diceValue%2 != 0
{
if dotNumber == 1
{
return CGPoint(x: (diceSize/2) - offSet, y: (diceSize/2) - offSet)
}
else
{
tempDiceValue = diceValue - 1
tempDotNumber = dotNumber - 1
}
}
for i in 1..<tempDotNumber+1
{
if tempDiceValue % 2 == 0
{
if i == 1
{
thisDot = CGPoint(x: zero - offSet, y: zero - offSet)
}
else if i == 2
{
thisDot = CGPoint(x: diceSize - zero - offSet, y: (diceSize - zero) - offSet)
}
else if i == 3
{
thisDot = CGPoint(x: (diceSize - zero) - offSet, y: zero - offSet)
}
else if i == 4
{
thisDot = CGPoint(x: zero - offSet, y: (diceSize - zero) - offSet)
}
else if i == 5
{
thisDot = CGPoint(x: zero - offSet, y: diceSize/2 - offSet)
}
else if i == 6
{
thisDot = CGPoint(x: (diceSize - zero) - offSet, y: center - offSet)
}
else if i == 7
{
thisDot = CGPoint(x: center - offSet, y: center-diceSize/(CGFloat(dotSizeDivisor)) - offSet)
}
else if i == 8
{
thisDot = CGPoint(x: center - offSet, y: center+diceSize/(CGFloat(dotSizeDivisor)) - offSet)
}
}
}
return thisDot
}
}
This is the code used in the app. The model has an array of Dice in it - either with 3 or 4 depending on the specific version of the game being played:
ForEach(viewModel.dice){dice in
DiceView(dice: dice)
.frame(width: 80, height: 80)
.onTapGesture(perform: {
if !viewModel.diceRolled
{
Sounds.playSounds(soundName: "diceroll")
}
viewModel.rollDice()
})
...and this is the Dice struct from the model:
struct Dice: Identifiable
{
var id: Int
var numberOfSides: Int
var currentValue: Int
}
Any help will be much appreciated! Thank you!
The issue was in the ForeEach loop that drew the dots... This answer had the solution. It needed to be identifiable.
View is not rerendered in Nested ForEach loop

SwiftUI: magnification gesture that changes value between min and max

I want to make the same thing that is possible to do with a slider:
#State itemSize: CGFloat = 55.60
....
Text("ItemSize: \(itemSize)")
Slider(value: $itemSize, in: 45...120)
but with magnification gesture.
I have created modifier for this:
import SwiftUI
#available(OSX 11.0, *)
public extension View {
func magnificationZoomer(value: Binding<CGFloat>,min: CGFloat, max: CGFloat) -> some View
{
self.modifier( MagnificationZoomerMod(minZoom: min, maxZoom: max, zoom: value) )
}
}
#available(OSX 11.0, *)
public struct MagnificationZoomerMod: ViewModifier {
let minZoom: CGFloat
let maxZoom: CGFloat
#Binding var zoom: CGFloat
public func body (content: Content) -> some View
{
content
.gesture(
MagnificationGesture()
.onChanged() { val in
let magnification = (val - 1) * 2.2
print("magnification = \(magnification)")
zoom = max(min(zoom + magnification, maxZoom), minZoom)
}
)
}
}
sample of usage:
#State itemSize: CGFloat = 55.60
var body: some View {
HStack {
VStack {
Text("ItemSize: \(itemSize)")
Slider(value: $itemSize, in: 45...120)
}
}
.frame(width: 500, height: 500)
.magnificationZoomer(value: $itemSize, min: 45, max: 120)
}
But I have a few problems with this code:
It slide value by a strange way -- sometimes with correct speed, it does not change it
after some time of usage it stop to work at all
What did I do wrong?
It works almost like a magic, you can change the size/scale of Circle via Slider or your finger on MagnificationGesture, both working together and sinked together.
import SwiftUI
struct ContentView: View {
let minZoom: CGFloat = 0.5
let maxZoom: CGFloat = 1.5
#State private var scale: CGFloat = 1.0
#State private var lastScale: CGFloat = 1.0
#State private var magnitudeIsActive: Bool = Bool()
var body: some View {
ZStack {
Circle()
.fill(Color.red)
.scaleEffect(scale)
.gesture(magnificationGesture.simultaneously(with: dragGesture)) // <<: Here: adding unneeded dragGesture! on macOS! no need on iOS!
VStack {
Spacer()
Text("scale: " + scale.rounded)
HStack {
Button("min") { scale = minZoom; lastScale = scale }
Slider(value: Binding.init(get: { () -> CGFloat in return scale },
set: { (newValue) in if !magnitudeIsActive { scale = newValue; lastScale = newValue } }), in: minZoom...maxZoom)
Button("max") { scale = maxZoom; lastScale = scale }
}
}
}
.padding()
.compositingGroup()
.shadow(radius: 10.0)
.animation(Animation.easeInOut(duration: 0.2), value: scale)
}
var magnificationGesture: some Gesture {
MagnificationGesture(minimumScaleDelta: 0.0)
.onChanged { value in
if !magnitudeIsActive { magnitudeIsActive = true }
let magnification = (lastScale + value.magnitude - 1.0)
if (magnification >= minZoom && magnification <= maxZoom) {
scale = magnification
}
else if (magnification < minZoom) {
scale = minZoom
}
else if (magnification > maxZoom) {
scale = maxZoom
}
}
.onEnded { value in
let magnification = (lastScale + value.magnitude - 1.0)
if (magnification >= minZoom && magnification <= maxZoom) {
lastScale = magnification
}
else if (magnification < minZoom) {
lastScale = minZoom
}
else if (magnification > maxZoom) {
lastScale = maxZoom
}
scale = lastScale
magnitudeIsActive = false
}
}
var dragGesture: some Gesture { DragGesture(minimumDistance: 0.0) } // <<: Here: this Extra un-needed gesture would keep magnificationGesture alive! And Stop it to get killed in macOS! We do not need this line of code in iOS!
}
extension CGFloat { var rounded: String { get { return String(Double(self*100.0).rounded()/100.0) } } }

Resources