Edit: Small changes to posted code to allow easier testing in tmp project. Create a new Single View App that using SwiftUI. C&P View A into ContentView.swift, and View B into a new files called "Dots"
I have also found that the issue does not occur when passing 0 to Dots, and have added that to the picker to aid testing.
I have spent countless hours doing my best to solve this. Read through too many other posts about this issue being caused by a lot of different things, hacked and slashed code and simple print statement/lldb to try track it down and I'm getting now - I'm pretty frantic right now.
This is the same core problem as a previous post
View works fine until used in NavigationView/Link
Whilst the workaround stopped the crashes, it was definitely a workaround and not fixing the source. As a consequence, a different "bug" appeared why a custom view partially ignored safe boundaries*. Rather than trying to find a hacky workaround for the workaround, it would be far better to try solve the core issue, but am deeply struggling.
As explained in the previous post, View A and B work fine on their own, but when navigation from A to B, that when it fails. Stepping through the code didn't yield much but it be a case of not knowing what to look for.
* this view consisted of x views that behaved like horizontal cards, swiping left/right to jump between them. This was achieved by having a HStack(spacing: 0) with a frame x times the width of the screen and a custom Gesture. If you envisage the views as an array, index 0 would be shown by default, and one can swipe amongst them. The first 2 items would correctly respect the inherited height, but 3 onwards would go behind the navigation space and status bar.
Note I've attempted to clear the code of some unnecessary content so as to avoid wasting your time.
View A (Note I've attempted to clear the code of some unnecessary content so as to avoid wasting your time.)
import SwiftUI
let lowerBound = 0
let upperBound = 101
struct CapsuleButton: ViewModifier {
func body(content: Content) -> some View {
content
.frame(width: 250, height: 100)
.background(Capsule().fill(Color.blue))
.foregroundColor(.black)
.font(.system(size: 28, weight: .light))
.padding()
}
}
struct ContentView: View {
@State private var pickerIndex: Int = UserDefaults.standard.integer(forKey: "highestDotCovered") - lowerBound
@State private var nav: Int? = 0
var body: some View {
VStack(spacing: 30) {
HStack {
Picker(selection: $pickerIndex, label: Text("")) {
ForEach(lowerBound..<upperBound) { Text(String($0)) }
}
}
Spacer()
NavigationLink(destination: Dots(lowerBound + pickerIndex), tag: 1, selection: $nav) { EmptyView() }
Text("Continue")
.modifier(CapsuleButton()) // custom look
.onTapGesture {
// Update storage
let currentHighest = UserDefaults.standard.integer(forKey: "highestDotCovered")
let highest = max(lowerBound + self.pickerIndex, currentHighest)
UserDefaults.standard.set(highest, forKey: "highestDotCovered")
// Go to destination
self.nav = 1
}
}
.navigationBarTitle("Quantity Selection")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ContentView()
}.navigationViewStyle(StackNavigationViewStyle())
}
}
View B
import SwiftUI
private let diam: CGFloat = 60 // ~1/3", 1pt = 1/163"
private let maxPhoneDots = 12
struct Dots: View {
@State private var dotAlert = false
let quantity: Int
let dot: some View = Circle()
.size(width: diam, height: diam)
.fill(Color.red)
@State var offsets: [CGSize] = []
init(_ quantity: Int) {
let model = UIDevice().model
self.quantity = model == "iPad" ? quantity : min(quantity, maxPhoneDots)
print(self.quantity)
}
var body: some View {
// NavigationView { // need to wrap geom reader in nav view to avoid crash
// VStack {
GeometryReader { geom in
ForEach(self.genOffsets(maxWidth: geom.size.width, maxHeight: geom.size.height), id: \.self) {offset in
self.dot
.offset(offset)
}
.background(Color.clear
.contentShape(Rectangle())) // workaround for allowing gestures on clear background
}
.border(Color.black)
// Spacer()
// Text("Dots: \(self.quantity)")
// }
// .navigationBarTitle("")
// .navigationBarHidden(true)
// }.navigationViewStyle(StackNavigationViewStyle())
}
}
extension Dots {
func genOffsets(maxWidth: CGFloat, maxHeight: CGFloat) -> [CGSize] {
var cols = UIDevice().model == "iPad" ? 8 : 4
var rows = UIDevice().model == "iPad" ? 13 : 5
if UIDevice().orientation.isLandscape {
(cols, rows) = (rows, cols) //swap cols and rows
}
var offsets: [CGSize] = []
let availableWidth = max(maxWidth - (CGFloat(cols) * diam), 0)
let colSeparation: CGFloat = availableWidth / CGFloat((cols+1))
let availableHeight = max(maxHeight - (CGFloat(rows) * diam), 0)
let rowSeparation: CGFloat = availableHeight / CGFloat((rows+1))
let xJitter = colSeparation * 0.49
let yJitter = rowSeparation * 0.49
// grid[row][col]
var grid = Array(repeating: Array(repeating: false, count: cols), count: rows)
// Generate dot offsets
for _ in 0..<quantity {
var r, c: Int
// Find a random empty cell
repeat {
r = Int.random(in: 0..<rows)
c = Int.random(in: 0..<cols)
} while grid[r][c]
grid[r][c] = true
// Calculate dot's offset
// Initial padding from TL corner
var x = colSeparation
var y = rowSeparation
// Separate dot into standard grid slot
x += CGFloat(c)*(diam+colSeparation)
y += CGFloat(r)*(diam+rowSeparation)
// Apply jitter
if xJitter != 0 { x += CGFloat.random(in: -xJitter ..< xJitter) }
if yJitter != 0 { y += CGFloat.random(in: -yJitter ..< yJitter) }
offsets.append(CGSize(width: x, height: y))
}
return offsets
}
}
extension CGSize: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(width)
hasher.combine(height)
}
}
struct Dots_Previews: PreviewProvider {
static var previews: some View {
Dots(1)
}
}
Thanks for showing an easily testable code.
With your updated code, I have tried a few things and I guess some SwiftUI structs (in this case ForEach) does not like changing its structure based on some unspecified states.
You may have tried already, but using an explicitly declared `@State` seems to solve the issue.
For your ViewB:
import SwiftUI
private let diam: CGFloat = 60 // ~1/3", 1pt = 1/163"
private let maxPhoneDots = 12
struct Dots: View {
let quantity: Int
let dot: some View = Circle()
.size(width: diam, height: diam)
.fill(Color.red)
@State var positions: [DotPosition] = [] //<-
init(_ quantity: Int) {
let model = UIDevice().model
self.quantity = model == "iPad" ? quantity : min(quantity, maxPhoneDots)
print(self.quantity)
self._positions = State(initialValue: createPositions())
}
var body: some View {
GeometryReader { geom in
ForEach(self.positions, id: \.self) {position in
self.dot
.offset(position.offset(maxWidth: geom.size.width, maxHeight: geom.size.height))
}
.background(Color.clear
.contentShape(Rectangle())) // workaround for allowing gestures on clear background
}
.border(Color.black)
}
}
struct DotPosition: Hashable {
var r: Int
var c: Int
var jitterScaleX: CGFloat
var jitterScaleY: CGFloat
}
extension Dots {
func createPositions() -> [DotPosition] {
var cols = UIDevice().model == "iPad" ? 8 : 4
var rows = UIDevice().model == "iPad" ? 13 : 5
if UIDevice().orientation.isLandscape {
(cols, rows) = (rows, cols) //swap cols and rows
}
var positions: [DotPosition] = []
// grid[row][col]
var grid = Array(repeating: Array(repeating: false, count: cols), count: rows)
// Generate dot offsets
for _ in 0..<quantity {
var r, c: Int
// Find a random empty cell
repeat {
r = Int.random(in: 0..<rows)
c = Int.random(in: 0..<cols)
} while grid[r][c]
grid[r][c] = true
// Apply jitter
let jitterScaleX = CGFloat.random(in: -1 ..< 1)
let jitterScaleY = CGFloat.random(in: -1 ..< 1)
positions.append(DotPosition(r: r, c: c, jitterScaleX: jitterScaleX, jitterScaleY: jitterScaleY))
}
return positions
}
}
extension DotPosition {
func offset(maxWidth: CGFloat, maxHeight: CGFloat) -> CGSize {
var cols = UIDevice().model == "iPad" ? 8 : 4
var rows = UIDevice().model == "iPad" ? 13 : 5
if UIDevice().orientation.isLandscape {
(cols, rows) = (rows, cols) //swap cols and rows
}
let availableWidth = max(maxWidth - (CGFloat(cols) * diam), 0)^
let colSeparation: CGFloat = (availableWidth / CGFloat((cols+1)))^
let availableHeight = max(maxHeight - (CGFloat(rows) * diam), 0)^
let rowSeparation: CGFloat = (availableHeight / CGFloat((rows+1)))^
let xJitter = colSeparation * 0.49
let yJitter = rowSeparation * 0.49
// Calculate dot's offset
// Initial padding from TL corner
var x = colSeparation
var y = rowSeparation
// Separate dot into standard grid slot
x += CGFloat(c)*(diam+colSeparation)
y += CGFloat(r)*(diam+rowSeparation)
// Apply jitter
if xJitter != 0 { x += jitterScaleX * xJitter }
if yJitter != 0 { y += jitterScaleY * yJitter }
return CGSize(width: x, height: y)
}
}