Why does this simple SwiftUI view modifier with transition produce a crash? - swiftui

I wrote this very simple .modal View modifier in SwiftUI.
When it is opened, I press close and before the transition finished open again, I get the following crash:
Gesture: Failed to receive system gesture state notification before next touch
Sample code:
import SwiftUI
struct ContentView: View {
#State var show = false
var body: some View {
VStack {
Button("Open") {
withAnimation {
self.show.toggle()
}
}.disabled(self.show) // doesn't help
}.modal(isShowing: self.$show) {
Button("Close") {
withAnimation {
self.show.toggle()
}
}
}
}
}
extension View {
func modal<C>(isShowing: Binding<Bool>, #ViewBuilder content: #escaping () -> C) -> some View where C: View {
self.modifier(ModalView(isShowing: isShowing, content: content))
}
}
struct ModalView<C>: ViewModifier where C: View {
#Binding var isShowing: Bool
let content: () -> C
func body(content: Content) -> some View {
ZStack {
content.zIndex(0)
if self.isShowing {
self.content()
.background(Color.primary.colorInvert())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.transition(.move(edge: .bottom))
.zIndex(1)
}
}
}
}
Can someone explain how I can prevent this? It seems that the Bool responsible for toggling is set before the animation has finished. Is this by design?

Yes, you need to disable below content (as you introduce modality) but in a bit different place
Here is fixed variant. Tested with Xcode 12 / iOS 14
struct ModalView<C>: ViewModifier where C: View {
#Binding var isShowing: Bool
let content: () -> C
#State private var interactive: Bool // track state
init(isShowing: Binding<Bool>, #ViewBuilder content: #escaping () -> C) {
self._isShowing = isShowing
self._interactive = State(initialValue: !isShowing.wrappedValue)
self.content = content
}
func body(content: Content) -> some View {
ZStack {
content.zIndex(0).disabled(!interactive) // disable here !!
if self.isShowing {
self.content()
.background(Color.primary.colorInvert())
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
.transition(.move(edge: .bottom))
.zIndex(1)
.onAppear { self.interactive = false } // << !!
.onDisappear { self.interactive = true } // << !!
}
}
}
}

Related

SwiftUI: Generic parameter 'Value' could not be inferred

Following codes gives an error of "Generic parameter 'Value' could not be inferred".
Appreciate anyone could point out the root cause and let me know how to fix it.
Thanks
import SwiftUI
private let readMe = """
This case study demonstrates how to enhance an existing SwiftUI component so that it can be driven \
off of optional and enum state.
The BottomMenuModifier component in this is file is primarily powered by a simple boolean binding, \
which means its content cannot be dynamic based off of the source of truth that drives its \
presentation, and it cannot make mutations to the source of truth.
However, by leveraging the binding transformations that come with this library we can extend the \
bottom menu component with additional APIs that allow presentation and dismissal to be powered by \
optionals and enums.
"""
struct ContentView: View {
#State var count: Int?
#State private var showPartialSheet = false
var body: some View {
Form {
Button("Show bottom menu") {
withAnimation {
self.count = 0
self.showPartialSheet = true
}
}
}
.bottomMenu($showPartialSheet, content: {
VStack {
Text("dfd")
}
})
.navigationTitle("Custom components")
}
}
private struct BottomMenuModifier<BottomMenuContent>: ViewModifier
where BottomMenuContent: View {
#Binding var isActive: Bool
let content: BottomMenuContent
init(isActive: Binding<Bool>, #ViewBuilder content: () -> BottomMenuContent) {
self._isActive = isActive
self.content = content()
}
func body(content: Content) -> some View {
content.overlay(
ZStack(alignment: .bottom) {
if self.isActive {
Rectangle()
.fill(Color.black.opacity(0.4))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
withAnimation {
self.isActive = false
}
}
.zIndex(1)
.transition(.opacity)
self.content
.padding()
.background(Color.white)
.cornerRadius(10)
.frame(maxWidth: .infinity)
.padding(24)
.padding(.bottom)
.zIndex(2)
.transition(.move(edge: .bottom))
}
}
.ignoresSafeArea()
)
}
}
extension View {
fileprivate func bottomMenu<Value, Content>(
_ showPartialSheet: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content
) -> some View
where Content: View {
self.modifier(
BottomMenuModifier(isActive: showPartialSheet, content: content)
)
}
}
struct CustomComponents_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Your bottomMenu function has an extra generic in its signature. Change it to:
fileprivate func bottomMenu<Content>(
(Note that Value from your original, which is unused, is removed)

How to stop SwiftUI DragGesture from animating subviews

I'm building a custom modal and when I drag the modal, any subviews that have animation's attached, they animate while I'm dragging. How do I stop this from happening?
I thought about passing down an #EnvironmentObject with a isDragging flag, but it's not very scalable (and doesn't work well with custom ButtonStyles)
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
.showModal(isShowing: .constant(true))
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
// I want this to not animate when dragging the modal
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(.spring())
}
}
UPDATE:
extension View {
func animationsDisabled(_ disabled: Bool) -> some View {
transaction { (tx: inout Transaction) in
tx.animation = tx.animation
tx.disablesAnimations = disabled
}
}
}
Container()
.animationsDisabled(isDragging || bottomState > 0)
In real life the Container contains a button with an animation on its pressed state
struct MyButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.spring())
}
}
Added the animationsDisabled function to the child view which does in fact stop the children moving during the drag.
What it doesn't do is stop the animation when the being initially slide in or dismissed.
Is there a way to know when a view is essentially not moving / transitioning?
Theoretically SwiftUI should not translate animation in this case, however I'm not sure if this is a bug - I would not use animation in Container in that generic way. The more I use animations the more tend to join them directly to specific values.
Anyway... here is possible workaround - break animation visibility by injecting different hosting controller in a middle.
Tested with Xcode 12 / iOS 14
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
Color.clear
if isShowing {
HelperView {
Container()
.background(Color.red)
}
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
bottomState = value.translation.height
}
.onEnded { _ in
if bottomState > 50 {
withAnimation {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
Color.clear
}
}
}
}
struct HelperView<Content: View>: UIViewRepresentable {
let content: () -> Content
func makeUIView(context: Context) -> UIView {
let controller = UIHostingController(rootView: content())
return controller.view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
In Container, declare a binding var so you can pass the bottomState to the Container View:
struct Container: View {
#Binding var bottomState: CGFloat
.
.
.
.
}
Dont forget to pass bottomState to your Container View wherever you use it:
Container(bottomState: $bottomState)
Now in your Container View, you just need to declare that you don't want an animation while bottomState is being changed:
Text("CONTAINER")
.frame(maxWidth: .infinity, maxHeight: 200)
.animation(nil, value: bottomState) // You Need To Add This
.animation(.spring())
In .animation(nil, value: bottomState), by nil you are asking SwiftUI for no animations, while value of bottomState is being changed.
This approach is tested using Xcode 12 GM, iOS 14.0.1.
You must use the modifiers of the Text in the order i put them. that means that this will work:
.animation(nil, value: bottomState)
.animation(.spring())
but this won't work:
.animation(.spring())
.animation(nil, value: bottomState)
I also made sure that adding .animation(nil, value: bottomState) will only disable animations when bottomState is being changed, and the animation .animation(.spring()) should always work if bottomState is not being changed.
So this is my updated answer. I don't think there is a pretty way to do it so now I am doing it with a custom Button.
import SwiftUI
struct ContentView: View {
#State var isShowing = false
var body: some View {
Text("Hello, world!")
.padding()
.onTapGesture(count: 1, perform: {
withAnimation(.spring()) {
self.isShowing.toggle()
}
})
.showModal(isShowing: self.$isShowing)
}
}
extension View {
func showModal(isShowing: Binding<Bool>) -> some View {
ViewOverlay(isShowing: isShowing, presenting: { self })
}
}
struct ViewOverlay<Presenting>: View where Presenting: View {
#Binding var isShowing: Bool
let presenting: () -> Presenting
#State var bottomState: CGFloat = 0
#State var isDragging = false
var body: some View {
ZStack(alignment: .center) {
presenting().blur(radius: isShowing ? 1 : 0)
VStack {
if isShowing {
Container()
.background(Color.red)
.offset(y: bottomState)
.gesture(
DragGesture()
.onChanged { value in
isDragging = true
bottomState = value.translation.height
}
.onEnded { _ in
isDragging = false
if bottomState > 50 {
withAnimation(.spring()) {
isShowing = false
}
}
bottomState = 0
})
.transition(.move(edge: .bottom))
}
}
}
}
}
struct Container: View {
var body: some View {
CustomButton(action: {}, label: {
Text("Pressme")
})
.frame(maxWidth: .infinity, maxHeight: 200)
}
}
struct CustomButton<Label >: View where Label: View {
#State var isPressed = false
var action: () -> ()
var label: () -> Label
var body: some View {
label()
.scaleEffect(self.isPressed ? 0.9 : 1.0)
.gesture(DragGesture(minimumDistance: 0).onChanged({_ in
withAnimation(.spring()) {
self.isPressed = true
}
}).onEnded({_ in
withAnimation(.spring()) {
self.isPressed = false
action()
}
}))
}
}
The problem is that you can't use implicit animations inside the container as they will be animated when it moves. So you need to explicitly set an animation using withAnimation also for the button pressed, which I now did with a custom Button and a DragGesture.
It is the difference between explicit and implicit animation.
Take a look at this video where this topic is explored in detail:
https://www.youtube.com/watch?v=3krC2c56ceQ&list=PLpGHT1n4-mAtTj9oywMWoBx0dCGd51_yG&index=11

How do I change button backgroundcolor if the button is disabled in swiftUI

I'm trying to create a buttonStyle that has a different background color if the button is disabled.
How do I do that?
I've created the code below to react to a variable that I've introduced myself, but is it possible to have it react on the buttons .disabled() state?
My code:
struct MyButtonStyle: ButtonStyle {
var enabledState = false
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.foregroundColor(Color.white)
.padding(10)
.padding(.horizontal, 20)
.background(self.enabledState ? Color(UIColor.orange) : Color(UIColor.lightGray))
.cornerRadius(20)
.frame(minWidth: 112, idealWidth: 112, maxWidth: .infinity, minHeight: 40, idealHeight: 40, maxHeight: 40, alignment: .center)
.scaleEffect(configuration.isPressed ? 0.9 : 1.0)
}
}
struct ContentView: View {
#State private var buttonEnabled = false
var body: some View {
HStack {
Button("Button") {
self.buttonEnabled.toggle()
print("Button pressed")
}
}
.buttonStyle(MyButtonStyle(enabledState: self.buttonEnabled))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
To track .disabled there is EnvironmentValues.isEnabled that shows this state. But environment values are applicable only to views and do not work in style.
So the solution is to create custom button that tracks isEnabled and pass it into own style.
Below is a demo of solution approach (MyButtonStyle is not changed). Tested with Xcode 12b.
struct MyButton: View {
let title: String
let action: () -> ()
#Environment(\.isEnabled) var isEnabled // to handle own state !!
init(_ title: String, action: #escaping () -> ()) {
self.title = title
self.action = action
}
var body: some View {
Button(title, action: action)
.buttonStyle(MyButtonStyle(enabledState: isEnabled))
}
}
struct ContentView: View {
#State private var buttonEnabled = true
var body: some View {
HStack {
MyButton("Button") { // << here !!
self.buttonEnabled.toggle()
print("Button pressed")
}
.disabled(!buttonEnabled) // << here !!
}
}
}
Again using the Environment.isEnabled but this time using a ViewModifier. This has the advantage that you can use it on other Views, not just Buttons. This implementation reduces the opacity of the button background color so no need for a new style to be injected.
struct MyButtonModifier: ViewModifier {
#Environment(\.isEnabled) var isEnabled
let backgroundColor: Color
func body(content: Content) -> some View {
content
.background(backgroundColor.opacity(isEnabled ? 1 : 0.5))
}
}
Then use it in your code as
Button("foo") {
// action
}
.modifier(MyButtonModifier(backgroundColor: Color.red))

Rectangle overlay on hovering a custom button

I'm trying to define a custom button style where on hover a rectangle pops up around that button.
struct CustomButtonStyle: ButtonStyle {
#State private var isOverButton = false
func makeBody(configuration: Self.Configuration) -> some View {
ZStack {
configuration.label
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color("Frost1"))
}
.padding(3)
.onHover { over in
self.isOverButton = over
print("isOverButton:", self.isOverButton, "over:", over)
}
.overlay(VStack {
if self.isOverButton {
Rectangle()
.stroke(Color("Frost1"), lineWidth: 2)
} else {
EmptyView()
}
})
}}
The print line shows me that setting the variable "isOverButton" is not working. Which type of variable state am I supposed to use that can be updated from "onHover" and updates "overlay"?
Here is a solution. Tested with Xcode 11.4.
struct TestOnHoverButton: View {
var body: some View {
Button("Button") {}
.buttonStyle(CustomButtonStyle())
}
}
struct CustomButtonStyle: ButtonStyle {
private struct CustomButtonStyleView<V: View>: View {
#State private var isOverButton = false
let content: () -> V
var body: some View {
ZStack {
content()
.frame(minWidth: 0, maxWidth: .infinity)
.padding()
.foregroundColor(.white)
.background(Color.blue)
}
.padding(3)
.onHover { over in
self.isOverButton = over
print("isOverButton:", self.isOverButton, "over:", over)
}
.overlay(VStack {
if self.isOverButton {
Rectangle()
.stroke(Color.blue, lineWidth: 2)
} else {
EmptyView()
}
})
}
}
func makeBody(configuration: Self.Configuration) -> some View {
CustomButtonStyleView { configuration.label }
}
}

InputAccessoryView / View Pinned to Keyboard with SwiftUI

Is there an equivalent to InputAccessoryView in SwiftUI (or any indication one is coming?)
And if not, how would you emulate the behavior of an InputAccessoryView (i.e. a view pinned to the top of the keyboard)? Desired behavior is something like iMessage, where there is a view pinned to the bottom of the screen that animates up when the keyboard is opened and is positioned directly above the keyboard. For example:
Keyboard closed:
Keyboard open:
iOS 15.0+
macOS 12.0+,Mac Catalyst 15.0+
ToolbarItemPlacement has a new property in iOS 15.0+
keyboard
On iOS, keyboard items are above the software keyboard when present, or at the bottom of the screen when a hardware keyboard is attached.
On macOS, keyboard items will be placed inside the Touch Bar.
https://developer.apple.com
struct LoginForm: View {
#State private var username = ""
#State private var password = ""
var body: some View {
Form {
TextField("Username", text: $username)
SecureField("Password", text: $password)
}
.toolbar(content: {
ToolbarItemGroup(placement: .keyboard, content: {
Text("Left")
Spacer()
Text("Right")
})
})
}
}
iMessage like InputAccessoryView in iOS 15+.
struct KeyboardToolbar<ToolbarView: View>: ViewModifier {
private let height: CGFloat
private let toolbarView: ToolbarView
init(height: CGFloat, #ViewBuilder toolbar: () -> ToolbarView) {
self.height = height
self.toolbarView = toolbar()
}
func body(content: Content) -> some View {
ZStack(alignment: .bottom) {
GeometryReader { geometry in
VStack {
content
}
.frame(width: geometry.size.width, height: geometry.size.height - height)
}
toolbarView
.frame(height: self.height)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
extension View {
func keyboardToolbar<ToolbarView>(height: CGFloat, view: #escaping () -> ToolbarView) -> some View where ToolbarView: View {
modifier(KeyboardToolbar(height: height, toolbar: view))
}
}
And use .keyboardToolbar view modifier as you would normally do.
struct ContentView: View {
#State private var username = ""
var body: some View {
NavigationView{
Text("Keyboar toolbar")
.keyboardToolbar(height: 50) {
HStack {
TextField("Username", text: $username)
}
.border(.secondary, width: 1)
.padding()
}
}
}
}
I got something working which is quite near the wanted result. So at first, it's not possible to do this with SwiftUI only. You still have to use UIKit for creating the UITextField with the wanted "inputAccessoryView". The textfield in SwiftUI doesn't have the certain method.
First I created a new struct:
import UIKit
import SwiftUI
struct InputAccessory: UIViewRepresentable {
func makeUIView(context: Context) -> UITextField {
let customView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 44))
customView.backgroundColor = UIColor.red
let sampleTextField = UITextField(frame: CGRect(x: 20, y: 100, width: 300, height: 40))
sampleTextField.inputAccessoryView = customView
sampleTextField.placeholder = "placeholder"
return sampleTextField
}
func updateUIView(_ uiView: UITextField, context: Context) {
}
}
With that I could finally create a new textfield in the body of my view:
import SwiftUI
struct Test: View {
#State private var showInput: Bool = false
var body: some View {
HStack{
Spacer()
if showInput{
InputAccessory()
}else{
InputAccessory().hidden()
}
}
}
}
Now you can hide and show the textfield with the "showInput" state. The next problem is, that you have to open your keyboard at a certain event and show the textfield. That's again not possible with SwiftUI and you have to go back to UiKit and making it first responder. If you try my code, you should see a red background above the keyboard. Now you only have to move the field up and you got a working version.
Overall, at the current state it's not possible to work with the keyboard or with the certain textfield method.
I've solved this problem using 99% pure SwiftUI on iOS 14.
In the toolbar you can show any View you like.
That's my implementation:
import SwiftUI
struct ContentView: View {
#State private var showtextFieldToolbar = false
#State private var text = ""
var body: some View {
ZStack {
VStack {
TextField("Write here", text: $text) { isChanged in
if isChanged {
showtextFieldToolbar = true
}
} onCommit: {
showtextFieldToolbar = false
}
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
}
VStack {
Spacer()
if showtextFieldToolbar {
HStack {
Spacer()
Button("Close") {
showtextFieldToolbar = false
UIApplication.shared
.sendAction(#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil)
}
.foregroundColor(Color.black)
.padding(.trailing, 12)
}
.frame(idealWidth: .infinity, maxWidth: .infinity,
idealHeight: 44, maxHeight: 44,
alignment: .center)
.background(Color.gray)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I managed to create a nicely working solution with some help from this post by Swift Student, with quite a lot of modification & addition of functionality you take for granted in UIKit. It is a wrapper around UITextField, but that's completely hidden from the user and it's very SwiftUI in its implementation. You can take a look at it in my GitHub repo - and you can bring it into your project as a Swift Package.
(There's too much code to put it in this answer, hence the link to the repo)
I have a implementation that can custom your toolbar
public struct InputTextField<Content: View>: View {
private let placeholder: LocalizedStringKey
#Binding
private var text: String
private let onEditingChanged: (Bool) -> Void
private let onCommit: () -> Void
private let content: () -> Content
#State
private var isShowingToolbar: Bool = false
public init(placeholder: LocalizedStringKey = "",
text: Binding<String>,
onEditingChanged: #escaping (Bool) -> Void = { _ in },
onCommit: #escaping () -> Void = { },
#ViewBuilder content: #escaping () -> Content) {
self.placeholder = placeholder
self._text = text
self.onEditingChanged = onEditingChanged
self.onCommit = onCommit
self.content = content
}
public var body: some View {
ZStack {
TextField(placeholder, text: $text) { isChanged in
if isChanged {
isShowingToolbar = true
}
onEditingChanged(isChanged)
} onCommit: {
isShowingToolbar = false
onCommit()
}
.textFieldStyle(RoundedBorderTextFieldStyle())
VStack {
Spacer()
if isShowingToolbar {
content()
}
}
}
}
}
You can do it this way without using a UIViewRepresentable.
Its based on https://stackoverflow.com/a/67502495/5718200
.onReceive(NotificationCenter.default.publisher(for: UITextField.textDidBeginEditingNotification)) { notification in
if let textField = notification.object as? UITextField {
let yourAccessoryView = UIToolbar()
// set your frame, buttons here
textField.inputAccessoryView = yourAccessoryView
}
}
}