SwiftUI: how to implement drawable grid? - swiftui

I'm trying to port my Pathfinding Visualizer web app from JS to SwiftUI on MacOS. Web App hosted on GitHub
Currently stuck on implementing the grid's input.
I want to be able to draw walls on the grid by dragging the cursor.
Full Code On GitHub
Here is my current method:
struct NodeView: View {
#State var nodeInfo: Node
#Binding var mouseDown: Bool
var body: some View {
Rectangle()
.fill(nodeColor(state: nodeInfo.getState()))
.frame(width: 25, height: 25)
.onHover { hover in
if mouseDown && hover {
print("mouse hover")
nodeInfo.toggleWall()
}
if hover {
print("node: \(nodeInfo.id) hovered!")
}
}
.pressAction {
if mouseDown == false {
mouseDown = true
print("mouse down")
nodeInfo.toggleWall()
}
} onRelease: {
mouseDown = false
print("mouse up")
}
}
The code for pressAction
struct PressActions: ViewModifier {
var onPress: () -> Void
var onRelease: () -> Void
func body(content: Content) -> some View {
content
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in
onPress()
})
.onEnded({ _ in
onRelease()
})
)
}
}
extension View {
func pressAction(onPress: #escaping (() -> Void), onRelease: #escaping (() -> Void)) -> some View {
modifier(PressActions(onPress: {
onPress()
}, onRelease: {
onRelease()
}))
}
}
The mindset is when a node detects that the mouse is pressed it will update the mouseDown variable passed down from the grid.
The other nodes on the grid will respond to the cursor hovering over them when mouseDown is true.
However this approach doesn't work as I wished. The nodes won't respond to the cursor when dragged across them most of the time. And even if it does, the response is quite laggy.
Is it because I'm using dragGesture to listen to mouse down and mouse up during the whole dragging action?
Currently trying to use NSEvent instead of gestures.
Update:
I've changed the implementation to using stateGesture on the content view instead of each node view.
But the time it takes from input to render seems proportional to the grid's size.
Also it seems the view won't update while the cursor is moving.

I've tried the implementation suggested by the post SwiftUI drag gesture across multiple subviews
This approach works. However, the input lag is proportional to the grid's size.
After profiling and searching for the cause of such behavior.
My theory is there's simply too much subviews inside contentView, causing SwiftUI to spend a considerable amount of time on diffing when the state changes.(If I'm wrong you're welcome to correct me)
I'm not sure if there is a way to solve this inside SwiftUI.
Eventually I choose to implement the grid view using UIKit's UIView.
With this approach I successfully implemented a grid that can draw walls with little to none latency.
If your interested with the code, the code is posted at GitHub

Related

Odd first run behavior, possible Swift timing issue?

I'm finishing up online auditing of Stanford CS193P class (great class BTW) and I have an oddity on my last assignment. I have created a theme data store and I use it to select a theme (color, number of pairs of cards, emoji) and then kick off and play a matching game. That works fine. Using an edit button, the user can edit a theme and change any of the theme elements.
I run into a problem the first time I use the edit button and select a theme to edit. My code acts as if the #State myEditTheme is nil. If I force unwrap it it crashes. I have put it in a nil-coalescing option as shown, the edit window comes up with the first theme in the array. Any subsequent edit attempts work normally.
In the tap gesture function, I set the value of the #State var myEditTheme, then I set the themeEditing to true. My debug print statement indicates that the myEditTheme has been properly set. When the sheet(isPresented: $themeEditing) presents the ThemeEditor in a "sheet" view, the initial value of myEditTheme is nil.
Is there a timing issue between when I set it in the tap function and when Swift senses that themeEditing is true? The code below is obviously not functional as is, I have edited it for conciseness, only showing relevant portions.
struct ThemeManager: View {
#EnvironmentObject var store: ThemeStore // injected
#State private var editMode: EditMode = .inactive
// inject a binding to List and Edit button
#State private var myEditTheme: Theme?
#State private var themeEditing = false
// used to control .sheet presentation for theme editing
var body: some View {
NavigationView {
List {
ForEach(store.themes) { theme in
NavigationLink(destination: ContentView(viewModel: EmojiMemoryGame(theme: theme))) {
VStack(alignment: .leading) {
Text(theme.name).font(.title2)
Text(theme.emojis).lineLimit(1)
} // end VStack
.sheet(isPresented: $themeEditing) {
ThemeEditor(theme: $store.themes[myEditTheme ?? theme])
.environmentObject(store)
}
.gesture(editMode == .active ? tap(theme) : nil)
} // end NavigationLink
} // end ForEach
} // end List
.navigationTitle("Themes")
.navigationBarTitleDisplayMode(.inline) // removes large title, leaves small inline one
.toolbar {
ToolbarItem { EditButton() }
ToolbarItem(placement: .navigationBarLeading) {
newThemeButton
}
}
.environment(\.editMode, $editMode)
} // NavigationView
} // body
private func tap(_ theme:Theme) -> some Gesture {
TapGesture().onEnded {
myEditTheme = theme
print("edit theme: \(myEditTheme)")
themeEditing = true
}
}

How do I make SwiftUI show entire vertical content on iPad?

I have a SwiftUI application that was laid out using an iPhone. Now when I run it on an iPad, it appears to fill the entire width of the screen, but much of the view content is cutoff on the top and bottom. The top level view contains a container (which can hold any number of different views, based on navigation) and a splash view, which times out after the animation. Is there a way to tell it to honor the size required to fit all of the vertical views, and auto-size the width?
This is the top level view. I can post more, but that is a lot of code.
import SwiftUI
struct ContentView: View {
#State var showSplash = true
var body: some View {
ZStack() {
ContainerView()
SplashView()
.opacity(showSplash ? 1 : 0)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
withAnimation() {
self.showSplash = false
splashDidFinish()
}
}
}
}.onAppear {
NSLog(".onAppear()")
}
}
func splashDidFinish() {
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "checkApplicationReady"), object: nil)
}
}
I was able to fix it using:
.aspectRatio(contentMode: .fit)

How to detect scroll up, scroll down, top and bottom in SwiftUI List

I have a List and I would like to hide a toolbar and tab bar when the List scrolls up.
I've tried to detect movements using the following:
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
print("Scroll down")
} else {
print("Scroll up")
}
}
.onEnded{ value in
print("ended")
}
)
This works intermittently, as in only around 80% of the time, it only seems to detect scrolls when I do it slowly or when I place my finger down straight onto the screen and then scroll. Otherwise nothing. Also, onEnded never fires here.
I also tried this:
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
print(value.translation.height)
if value.translation.height > 0 {
print("Scroll down")
} else {
print("Scroll up")
}
}
.onEnded{ value in
print("ended")
print(value.location.y)
}
)
This catches all my scrolling, which is great. onEnded also fires, but the problem is my List doesn't actually scroll.
I'm at a loss here, I want to know when the user scrolls up, scrolls down, by how much, and if possible I would like to know when the user gets to the top of the list.
If you want to do this SwiftUI only you can make a GeometryReader inside the ScrollView to detect offset change like shown here: https://fivestars.blog/swiftui/scrollview-offset.html
If you don't mind using Introspect you can do it like described in this answer: https://stackoverflow.com/a/65467199/3393964
You can use Introspect to get the UIScrollView, then from that get the publisher for UIScrollView.contentOffset and UIScrollView.isDragging to get updates on those values which you can use to manipulate your SwiftUI views.
struct Example: View {
#State var isDraggingPublisher = Just(false).eraseToAnyPublisher()
#State var offsetPublisher = Just(.zero).eraseToAnyPublisher()
var body: some View {
...
.introspectScrollView {
self.isDraggingPublisher = $0.publisher(for: \.isDragging).eraseToAnyPublisher()
self.offsetPublisher = $0.publisher(for: \.contentOffset).eraseToAnyPublisher()
}
.onReceive(isDraggingPublisher) {
// do something with isDragging change
}
.onReceive(offsetPublisher) {
// do something with offset change
}
...
}
If you want to look at an example; I use this method to get the offset publisher in my package ScrollViewProxy.

How to dismiss keyboard on swipe like in WhatsApp in SwiftUI

I know there are answers for dismissing keyboard, but mostly they are triggered on tapped outside of the keyboard.
As I stated in the question, how to achieve dismissing keyboard on swipe (to bottom).
UIScrollView has keyboardDismissMode which when set to interactive, will achieve what you want. SwiftUI doesn’t provide direct support for this, but since under the hood, SwiftUI is using UIScrollView, you can use this which sets keyboardDismissMode to interactive for all scroll views in your app.
UIScrollView.appearance().keyboardDismissMode = .interactive
You must have a ScrollView in your view hierarchy for this to work. Here’s is a simple view demonstrating the behavior:
struct ContentView: View {
#State private var text = "Hello, world!"
var body: some View {
ScrollView {
TextField("Hello", text: $text)
.padding()
}
.onAppear {
UIScrollView.appearance().keyboardDismissMode = .interactive
}
}
}
The only caveat is that this affects all scroll views in your app. I don’t know of a simple solution if you only want to affect one scroll view in your app.
For example if you have a list of messages then you can :
List {
ForEach(...) { ...
}
}.resignKeyboardOnDragGesture()
extension View {
func resignKeyboardOnDragGesture() -> some View {
return modifier(ResignKeyboardOnDragGesture())
}
}
struct ResignKeyboardOnDragGesture: ViewModifier {
var gesture = DragGesture().onChanged { _ in
UIApplication.shared.endEditing(true)
}
func body(content: Content) -> some View {
content.gesture(gesture)
}
}
By the way it came from here : https://stackoverflow.com/a/58564739/7974174
You can add
.simultaneousGesture(
// Hide the keyboard on scroll
DragGesture().onChanged { _ in
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil,
from: nil,
for: nil
)
}
)
to the view.

Why isn't onPreferenceChange being called if it's inside a ScrollView in SwiftUI?

I've been seeing some strange behavior for preference keys with ScrollView. If I put the onPreferenceChange inside the ScrollView it won't be called, but if I put it outside it does!
I've setup a width preference key as follows:
struct WidthPreferenceKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat(0)
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
The following simple view does not print:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
But this works:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
I know that I can use the latter approach to fix this, but sometimes I'm inside a child view that does not have access to its parent scroll view but I still want to record a preference key.
Any ideas on how to get onPreferenceChange to get called inside a ScrollView?
Note: I get Bound preference WidthPreferenceKey tried to update multiple times per frame. when I put the function inside the scroll view, which might explain what is going on but I can't figure it out.
Thanks!
I had been trying to figure out this issue for a long time and have found how to deal with it, although the way I used was just one of the workarounds.
Use onAppear to ScrollView with a flag to make its children show up.
...
#State var isShowingContent = false
...
ScrollView {
if isShowingContent {
ContentView()
}
}
.onAppear {
self.isShowingContent = true
}
Or,
Use List instead of it.
It has the scroll feature, and you can customize it with its own functionality and UITableView appearance in terms of UI. the most important is that it works as we expected.
[If you have time to read more]
Let me say my thought about that issue.
I have confirmed that onPreferenceChange isn't called at the bootstrap time of a view put inside a ScrollView. I'm not sure if it is the right behavior or not. But, I assume that it's wrong because ScrollView has to be capable of containing any views even if some of those use PreferenceKey to pass any data among views inside it. If it's the right behavior, it would be quite easy for us to get in trouble when creating our custom views.
Let's get into more detail.
I suppose that ScrollView would work slightly different from the other container views such as List, (H/V)Stack when it comes to set up its child view at the bootstrap time. In other words, ScrollView would try to draw(or lay out) children in its own way. Unfortunately, that way would affect the children's layout mechanism working incorrectly as what we've been seeing. We could guess what happened with the following message on debug view.
TestHPreferenceKey tried to update multiple times per frame.
It might be a piece of evidence to tell us that the update of children has occurred while ScrollView is doing something for its setup. At that moment, it could be guessed that the update to PreferenceKey has been ignored.
That's why I tried to put the placing child views off to onAppear.
I hope that will be useful for someone who's struggling with various issues on SwiftUI.
I think onPreferenceChange in your example is not called because it’s function is profoundly different from preference(key…)
preference(key:..) sets a preference value for the view it is used on.
whereas onPreferenceChange is a function called on a parent view – a view on a higher position in the view tree hierarchy. Its function is to go through all its children and sub-children and collect their preference(key:) values. When it found one it will use the reduce function from the PreferenceKey on this new value and all the already collected values. Once it has all the values collected and reduced them it will execute the onPreference closure on the result.
In your first example this closure is never called because the Text(“Hello”) view has no children which set the preference key value (in fact the view has no children at all). In your second example the Scroll view has a child which sets its preference value (the Text view).
All this does not explain the multiple times per frame error – which is most likely unrelated.
Recent update (24.4.2020):
In a similar case I could induce the call of onPreferenceChange by changing the Equatable condition for the PreferenceData. PreferenceData needs to be Equatable (probably to detect a change in them). However, the Anchor type by itself is not equatable any longer. To extract the values enclosed in an Anchor type a GeometryProxy is required. You get a GeometryProxy via a GeometryReader. For not disturbing the design of views by enclosing some of them into a GeometryReader I generated one in the equatable function of the PreferenceData struct:
struct ParagraphSizeData: Equatable {
let paragraphRect: Anchor<CGRect>?
static func == (value1: ParagraphSizeData, value2: ParagraphSizeData) -> Bool {
var theResult : Bool = false
let _ = GeometryReader { geometry in
generateView(geometry:geometry, equality:&theResult)
}
func generateView(geometry: GeometryProxy, equality: inout Bool) -> Rectangle {
let paragraphSize1, paragraphSize2: NSSize
if let anAnchor = value1.paragraphRect { paragraphSize1 = geometry[anAnchor].size }
else {paragraphSize1 = NSZeroSize }
if let anAnchor = value2.paragraphRect { paragraphSize2 = geometry[anAnchor].size }
else {paragraphSize2 = NSZeroSize }
equality = (paragraphSize1 == paragraphSize2)
return Rectangle()
}
return theResult
}
}
With kind regards
It seems like the issue is not necessarily with ScrollView, but with your usage of PreferenceKey. For instance, here is a sample struct in which a PreferenceKey is set according to the width of a Rectangle, and then printed using .onPreferenceChange(), all inside of a ScrollView. As you drag the Slider to change the width, the key is updated and the print closure is executed.
struct ContentView: View {
#State private var width: CGFloat = 100
var body: some View {
VStack {
Slider(value: $width, in: 100...200)
ScrollView(.vertical) {
Rectangle()
.background(WidthPreferenceKeyReader())
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
.frame(width: self.width)
}
}
}
struct WidthPreferenceKeyReader: View {
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: WidthPreferenceKey.self, value: geometry.size.width)
}
}
}
As you noted, the first time the key tries to set, the console prints "Bound preference WidthPreferenceKey tried to update multiple times per frame," but a real value is immediately set afterward, and it continues to update dynamically.
What value are you actually trying to set, and what are you trying to do in .onPreferenceChange()?
I think this is because you implemented reduce() incorrectly.
You can find the details here:
https://stackoverflow.com/a/73300115/4366470
TL;DR: Replace value = nextValue() in reduce() with value += nextValue().
You may only read it in superView, but you can change it with transformPreference after you set it .
struct ContentView: View {
var body: some View {
ScrollView {
VStack{
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}.transformPreference(WidthPreferenceKey.self, {
$0 = 30})
}.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
The last value is 30 now. Hope it is what you want.
You can read from other layer:
ScrollView {
Text("Hello").preference(key: WidthPreferenceKey.self, value: CGFloat(40.0))
.backgroundPreferenceValue(WidthPreferenceKey.self) { x -> Color in
print(x)
return Color.clear
}
}
The problem here is actually not in ScrollView but in usage - this mechanism allow to transfer data up in viewTree:
A view with multiple children automatically combines its values for a
given preference into a single value visible to its ancestors.
source
The keywords here - with multiple children. This mean that u can pass it in viewTree from child to parent.
Let's review u'r code:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
.onPreferenceChange(WidthPreferenceKey.self) {
print($0) // Not being called, we're in a scroll view.
}
}
}
}
As u can see now - child pass value to itself, and not to parent - so this don't want to work, as per design.
And working case:
struct ContentView: View {
var body: some View {
ScrollView {
Text("Hello")
.preference(key: WidthPreferenceKey.self, value: 20)
}
.onPreferenceChange(WidthPreferenceKey.self) {
print($0)
}
}
}
Here, ScrollView is parent and Text is child, and child talk to parent - everything works as expected.
So, as I sad in the beginning the problem here not in ScrollView but in usage and in Apple documentation (u need to read it few times as always).
And regarding this:
Bound preference WidthPreferenceKey tried to update multiple times per
frame.
This is because u may change multiply values in same time and View can't be rendered, try to .receive(on:) or DispatchQueue.main.async as workaround (I guess this may be a bug)