SwiftUI LazyVGrid EXC_BREAKPOINT when scrolling after GridItem size change - swiftui

I want a SwiftUI LazyGrid that can change its GridItem size with an animation, but a crash occurs while scrolling up after the size is made smaller.
Steps to Reproduce:
1 - scroll to bottom of 'xLarge' size grid
2 - change to 'large' size using Picker
3 - scroll up after size change animation has finished
Crash Error: Thread 1: EXC_BREAKPOINT (code=1, subcode=0x18d9f3b64)
Code Snippets:
import SwiftUI
#main
struct GridScrollDefectApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
struct ContentView: View {
#State var cellSize: CellSize = .xLarge
var body: some View {
let sizes = cellSize.adaptiveSizes
let objects = Array(0..<30).map { _ in return TestObject() }
VStack {
Picker("Size", selection: $cellSize.animation()) {
ForEach(CellSize.allCases) { cellSize in
Text(cellSize.rawValue.localizedCapitalized)
.tag(cellSize)
}
}
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: sizes.min, maximum: sizes.max))]) {
ForEach(objects) { _ in
Image(systemName: "multiply")
.resizable()
.aspectRatio(contentMode: .fit)
}
}
}
}
}
}
import Foundation
class TestObject: Identifiable {
var id: UUID = UUID()
}
enum CellSize: String, CaseIterable, Identifiable {
case xLarge, large
var id: RawValue { return rawValue }
var adaptiveSizes: (min: CGFloat, max: CGFloat) {
switch self {
case .xLarge:
return (330,400)
case .large:
return (150,200)
}
}
}
I've tried various combinations of storing the cellSize and the objects array in #State, #Binding, and a #ObservedObject #Published var, and all exhibit this issue. The only half-work-around I've found is to refresh the views using a .id() modifier on the grid, which avoids the crash, but also removes the resizing animation.

Related

Additional safe area on NavigationView in SwiftUI

I am building a SwiftUI app where I have an overlay that is conditionally shown across my entire application like this:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
.safeAreaInset(edge: .bottom) {
Group {
if myCondition {
EmptyView()
} else {
OverlayView()
}
}
}
}
}
}
I would expect this to adjust the safe area insets of the NavigationView and propagate it to any content view, so content is not stuck under the overlay. At least that's how additionalSafeAreaInsets in UIKit would behave. Unfortunately, it seems that SwiftUI ignores any safeAreaInsets() on a NavigationView (the overlay will show up, but safe area is not adjusted).
While I can use a GeometryReader to read the overlay size and then set safeAreaInsets() on ContentView, this will only work for ContentView - as soon as I navigate to the next view the safe area is gone.
Is there any nice way to get NavigationView to accept additional safe area insets, either by using safeAreaInsets() or by some other way?
So it seems NavigationView does not adjust its safe area inset when using .safeAreaInset. If this is intended or a bug is not clear to me. Anyway, I solved this for now like this (I wanted to use pure SwiftUI, using UIKit's additionalSafeAreaInsets might be an option to):
Main App File:
#main
struct MyApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
.environmentObject(SafeAreaController.shared)
.safeAreaInset(edge: .bottom) {
OverlayView()
.frameReader(safeAreaController.updateAdditionalSafeArea)
}
}
}
}
class SafeAreaController: ObservableObject {
static let shared = SafeAreaController()
#Published private(set) var additionalSafeArea: CGRect = .zero
func updateAdditionalSafeArea(_ newValue: CGRect) {
if newValue != additionalSafeArea {
additionalSafeArea = newValue
}
}
}
struct FrameReader: ViewModifier {
let changeChandler: ((CGRect) -> Void)
init(_ changeChandler: #escaping (CGRect) -> Void) {
self.changeChandler = changeChandler
}
func body(content: Content) -> some View {
content
.background(
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let newFrame = geometry.frame(in: .global)
changeChandler(newFrame)
}
return Color.clear
}
)
}
}
extension View {
func frameReader(_ changeHandler: #escaping (CGRect) -> Void) -> some View {
return modifier(FrameReader(changeHandler))
}
}
EVERY Content View that is pushed on your NavigationView:
struct ContentView: View {
#EnvironmentObject var safeAreaController: SafeAreaController
var body: some View {
YourContent()
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: safeAreaController.additionalSafeArea.height)
}
}
Why does it work?
In the main app file, a GeometryReader is used to read the size of the overlay created inside safeAreaInset(). The size is written to the shared SafeAreaController
The shared SafeAreaController is handed as an EnvironmentObject to every content view of our navigation
An invisible object is created as the .safeAreaInset of every content view with the height read from the SafeAreaController - this will basically create an invisible bottom safe area that is the same size as our overlay, thus making room for the overlay
struct ContentView: View {
var body: some View {
NavigationView {
ListView()
.navigationBarTitle("Test")
}
.safeAreaInset(edge: .bottom) {
HStack {
Spacer()
Text("My Overlay")
.padding()
Spacer()
}
.background(.ultraThinMaterial)
}
}
}
struct ListView: View {
var body: some View {
List(0..<30) { item in
NavigationLink {
Text("Next view")
} label: {
Text("Item \(item)")
}
}
}
}

SwiftUI List messed up after delete action on iOS 15

It seems that there is a problem in SwiftUI with List and deleting items. The items in the list and data get out of sync.
This is the code sample that reproduces the problem:
import SwiftUI
struct ContentView: View {
#State var popupShown = false
var body: some View {
VStack {
Button("Show list") { popupShown.toggle() }
if popupShown {
MainListView()
}
}
.animation(.easeInOut, value: popupShown)
}
}
struct MainListView: View {
#State var texts = (0...10).map(String.init)
func delete(at positions: IndexSet) {
positions.forEach { texts.remove(at: $0) }
}
var body: some View {
List {
ForEach(texts, id: \.self) { Text($0) }
.onDelete { delete(at: $0) }
}
.frame(width: 300, height: 300)
}
}
If you perform a delete action on the first row and scroll to the last row, the data and list contents are not in sync anymore.
This is only happening when animation is attached to it. Removing .animation(.easeInOut, value: popupShown) workarounds the issue.
This code sample works as expected on iOS 14 and doesn't work on iOS 15.
Is there a workaround for this problem other then removing animation?
It isn't the animation(). The clue was seeing It appears that having the .animation outside of the conditional causes the problem. Moving it to the view itself corrected it to some extent. However, there is a problem with this ForEach construct: ForEach(texts, id: \.self). As soon as you start deleting elements of your array, the UI gets confused as to what to show where. You should ALWAYS use an Identifiable element in a ForEach. See the example code below:
struct ListDeleteView: View {
#State var popupShown = false
var body: some View {
VStack {
Button("Show list") { popupShown.toggle() }
if popupShown {
MainListView()
.animation(.easeInOut, value: popupShown)
}
}
}
}
struct MainListView: View {
#State var texts = (0...10).map({ TextMessage(message: $0.description) })
func delete(at positions: IndexSet) {
texts.remove(atOffsets: positions)
}
var body: some View {
List {
ForEach(texts) { Text($0.message) }
.onDelete { delete(at: $0) }
}
.frame(width: 300, height: 300)
}
}
struct TextMessage: Identifiable {
let id = UUID()
let message: String
}

Can I create a ScrollView that is infinite scrolling but has fixed values?

Given a ScrollView like the following
If I have a ScrollView like the one below, can I display 1 to 10 again after the CircleView of 1 to 10?
I want to use the same 1-10 values and display 1, 2, 3....10 after 10. I want to use the same 1...10 values and display 1, 2, 3...10 after 10.
struct ContentView: View {
var body: some View {
VStack {
Divider()
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<10) { index in
CircleView(label: "\(index)")
}
}.padding()
}.frame(height: 100)
Divider()
Spacer()
}
}
}
struct CircleView: View {
#State var label: String
var body: some View {
ZStack {
Circle()
.fill(Color.yellow)
.frame(width: 70, height: 70)
Text(label)
}
}
}
reference:
https://www.simpleswiftguide.com/how-to-create-horizontal-scroll-view-in-swiftui/
This can be done like illustrated in the Advanced ScrollView Techniques video by Apple. Although that video is from before the SwiftUI era, you can easily implement it in SwiftUI. The advantage of this technique (compared to Toto Minai's answer) is that it does not need to allocate extra memory when scrolling up or down (And you will not run out of memory when you scroll too far 😉).
Here is an implementation in SwiftUI.
import SwiftUI
let overlap = CGFloat(100)
struct InfiniteScrollView<Content: View>: UIViewRepresentable {
let content: Content
func makeUIView(context: Context) -> InfiniteScrollViewRenderer {
let contentWidth = CGFloat(100)
let tiledContent = content
.float(above: content) // For an implementation of these modifiers:
.float(below: content) // see https://github.com/Dev1an/SwiftUI-InfiniteScroll
let contentController = UIHostingController(rootView: tiledContent)
let contentView = contentController.view!
contentView.frame.size.height = contentView.intrinsicContentSize.height
contentView.frame.size.width = contentWidth
contentView.frame.origin.y = overlap
let scrollview = InfiniteScrollViewRenderer()
scrollview.addSubview(contentView)
scrollview.contentSize.height = contentView.intrinsicContentSize.height * 2
scrollview.contentSize.width = contentWidth
scrollview.contentOffset.y = overlap
return scrollview
}
func updateUIView(_ uiView: InfiniteScrollViewRenderer, context: Context) {}
}
class InfiniteScrollViewRenderer: UIScrollView {
override func layoutSubviews() {
super.layoutSubviews()
let halfSize = contentSize.height / 2
if contentOffset.y < overlap {
contentOffset.y += halfSize
} else if contentOffset.y > halfSize + overlap {
contentOffset.y -= halfSize
}
}
}
The main idea is to
Tile the static content. This is done using the float() modifiers.
Change the offset of the scrollview to replace the current view with a previous or next tile when you reach a bound. This is done in layoutSubviews of the InfiniteScrollViewRenderer
Drawback
The main drawback of this technique is that up until now (July 2021) Lazy Stacks don't appear to be rendered lazily when they are not inside a SwiftUI List or ScrollView.
You could use LazyHStack to add a new item when the former item appeared:
// Use `class`, since you don't want to make a copy for each new item
class Item {
var value: Int
// Other properties
init(value: Int) {
self.value = value
}
}
struct ItemWrapped: Identifiable {
let id = UUID()
var wrapped: Item
}
struct ContentView: View {
static let itemRaw = (0..<10).map { Item(value: $0) }
#State private var items = [ItemWrapped(wrapped: itemRaw.first!)]
#State private var index = 0
var body: some View {
VStack {
Divider()
// Scroll indicator might be meaningless?
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 10) {
ForEach(items) { item in
CircleView(label: item.wrapped.value.formatted())
.onAppear {
// Index iteration
index = (index + 1) % ContentView.itemRaw.count
items.append(
ItemWrapped(wrapped: ContentView.itemRaw[index]))
}
}
}.padding()
}.frame(height: 100)
Divider()
Spacer()
}
}
}
You can do it in SwiftUI but it doesn’t use a ScrollView. Try this View available here: https://gist.github.com/kevinbhayes/550e4b080d2761aa20d351ff01bab13e

How to create an SwiftUI animation effect from the Model?

I have a model object, which has a published property displayMode, which is updated asynchronously via events from the server.
class RoomState: NSObject, ObservableObject {
public enum DisplayMode: Int {
case modeA = 0
case modeB = 1
case modeC = 2
}
#Published var displayMode = DisplayMode.modeA
func processEventFromServer(newValue: DisplayMode) {
DispatchQueue.main.async {
self.displayMode = newValue
}
}
}
Then, I have a View, which displays this mode by placing some image in a certain location depending on the value.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
}
}
}
This code works fine, but I want to animate the movement when the value changes. If I change the value in the code block inside the View, I can use withAnimation {..} to create an animation effect, but I am not able to figure out how to do it from the model.
This is the answer, thanks to #aheze. With .animation(), this Image view always animates when the state.displayMode changes.
struct RoomView: View {
#ObservedObject var state: RoomState
var body: some View {
VStack {
...
Image(systemName: "something")
.offset(x: state.displayMode.rawValue * 80, y:0)
.animation(.easeInOut)
}
}
}

Invisible transition removal animation

I've encountered an issue that I was not able to tinker to my full satisfaction.
I have a MasterView that changes environmentObject SelectionObject to show ZStack content* from Link enum. The issue is that the removal transition is almost invisible when there is a background in MasterView (Color.gray, when I set opacity, the animation is visible a little bit but unless it gets to low number, the overall opacity of FirstView or SecondView is detrimented. It works as expected without any background in MasterView
Here is my code:
class SelectionObject: ObservableObject {
#Published var selection: Link? = nil
}
struct MasterView: View {
#EnvironmentObject var selection: SelectionObject
var body: some View {
ZStack {
Color.gray
VStack {
ForEach(Link.allCases) { menu in
Button(action: {
selection.selection = menu
}, label: {
Label(menu.title, systemImage: menu.image).padding()
}
)
.tag(menu)
}
}
ForEach(Link.allCases) { menu in
if menu == selection.selection {
menu.contentView
.transition(AnyTransition.slide)
.animation(.spring())
}
}
}
}
}
struct Menu_Previews: PreviewProvider {
static var previews: some View {
MasterView().environmentObject(SelectionObject())
}
}
struct FirstView: View {
#EnvironmentObject var selection: SelectionObject
var body: some View {
ZStack {
Color.orange
VStack {
Text("First View content")
Button(action: {
selection.selection = nil
}, label: {
Text("Get back with a nice animation").padding().foregroundColor(.white)
}
)
}
}
}
}
struct SecondView: View {
#EnvironmentObject var selection: SelectionObject
var body: some View {
ZStack {
Color.orange
VStack {
Text("Second View content")
Button(action: {
selection.selection = nil
}, label: {
Text("Get back with a nice animation")
}
)
}
}
}
}
enum Link: Int, CaseIterable, Identifiable {
var id: Int {
return self.rawValue
}
case first
case second
var title: LocalizedStringKey {
switch self {
case .first: return "First"
case .second: return "Second"
}
}
var image: String {
switch self {
case .first: return "icloud"
case .second: return "display"
}
}
var contentView: AnyView {
switch self {
case .first: return AnyView ( FirstView() )
case .second: return AnyView ( SecondView() )
}
}
}
I've tried to use a zIndex way (mentioned here: Transition animation not working in SwiftUI ) but was unable to make it work as it worked only once and did not show the content on second click.
Can you help me find a way around the issue?
I use this because I can't use NavigationView as my MasterView is used in overlay in a different NavigationView and there is a frame, offset, and cornerRadius issue that prevents to click on anything unless I delete either the offset or cornerRadius.
Just add a zIndex to your menu.contentView and it will be always on top. Hence, you can see the back animation.
menu.contentView
.id(UUID()) // << add id here
.transition(AnyTransition.slide)
.animation(.spring())
.zIndex(50) //<< set higher zIndex here
Works multiple times aswell, after toggling view multiple times
Edit: Transition will fade in from leading edge and will dismiss to trailing edge. As the view stay there it will fade in back (once it is called again) from the trailing edge. With id(UUID() you create a new one which fades back from leading to trailing