SwiftUI implement keyUp functionality - swiftui

I am trying to implement a suggestions popover functionality in SwiftUI. I have implemented it using onChange. But when the user clicks on any of the suggestions in the popover the onChange event is triggered again, which is not what I want. Ideally, I should fetch the suggestions when the user has ended typing, but I cannot find a better way to get this done.
Any help is appreciated.
TextField("", text: $keyword, prompt: Text("Required"))
.onChange(of: keyword, perform: { keywordSearch in
showsAlwaysPopover = true
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: {
debouncer.run(action: {
self.store.fetchKeywordSuggestions(keyword: keywordSearch)
})
})
})
.alwaysPopover(isPresented: $showsAlwaysPopover) {
if let result = self.store.keywordSuggestions {
VStack {
ForEach(result, id: \.self) { i in
HStack {
Text(i)
}
.onTapGesture {
keyword = i
}
}
}
.foregroundColor(Color.gray)
.padding(.all)
}

Related

LazyVStack in ScrollView has jittery auto scroll

I have a chat app, where whenever a chat room is opened, I need the view to scroll to the bottom as soon as the messages are fetched.
The thing is that although it does scroll perfectly when a new message is received or sent (see ViewModel down below), it is very jittery when I tell it to scroll right after the first batch of messages is fetched, which happens once as soon as the view appears.
After a lot of trial and error, I realized that if I add a small delay to the scroll, it'll improve but not completely! It is like it's trying to scroll to the very bottom, but it'll fail just for a few inches. I also realized that if I add a bigger delay, like 2 seconds, it'll scroll just fine.
Here's the messages list view:
struct MessagesView: View {
#StateObject private var viewModel = ViewModel()
// -----------------------
let currentChatRoom: ChatRoom
// -----------------------
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack {
ScrollView {
ScrollViewReader { proxy in
LazyVStack {
ForEach(viewModel.messages) { message in
MessageView(message: message)
.id(message.id)
.onTapGesture {
viewModel.shouldDismissKeyboard = true
}
}
}
.onChange(of: viewModel.shouldScrollToMessageId) { messageId in
if let messageId = messageId, !messageId.isEmpty {
proxy.scrollTo(messageId, anchor: .bottom)
}
}
}
}
VStack(alignment: .leading) {
if chatEnvironment.isOtherUserTyping {
TypingIndicationView()
}
BottomView()
.padding(.bottom, 4)
}
}
}
.onAppear {
viewModel.setUp(currentChatRoom: currentChatRoom)
}
}
}
As you can see, it’s viewModel.shouldScrollToMessageId that’s responsible for "auto-scrolling" to the last message.
Here’s MessageView:
fileprivate struct MessageView: View {
let message: Message
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 1) {
Text(message.user.isCurrentUser == true ? "You" : "\(message.user.username)")
.foregroundColor(message.user.isCurrentUser == true ? .customGreen : .customBlue)
.multilineTextAlignment(.leading)
.font(.default(size: 16))
.padding(.bottom, 1)
if let imageURL = message.postSource?.imageURL, !imageURL.isEmpty {
VStack(alignment: .leading) {
WebImage(url: .init(string: imageURL))
.resizable()
.indicator(.activity)
.scaledToFill()
.frame(width: UIScreen.main.bounds.width / 2, height: UIScreen.main.bounds.width / 1.45)
.cornerRadius(25)
}
}
Text(message.text)
.foregroundColor(.white)
.multilineTextAlignment(.leading)
.font(.default(size: 16))
}
Spacer()
}
.padding(.bottom, 8)
.padding(.horizontal)
.background(
Color.black
)
}
}
Here’s the ViewModel:
class ViewModel: ObservableObject {
#Published var messages = [Message]()
#Published var text = ""
#Published var shouldScrollToMessageId: String?
#Published var currentChatRoom: ChatRoom?
// -----------------------------
private var isInitialized = false
// -----------------------------
func setUp(currentChatRoom: ChatRoom) {
guard !isInitialized else { return }
isInitialized.toggle()
// -----------------------------
self.currentChatRoom = currentChatRoom
// -----------------------------
getFirstBatchOfMessages(chatRoom: chatRoom)
subscribeToNewMessages()
}
private func getFirstBatchOfMessages(chatRoom: ChatRoom) {
messagesService.getMessages(chatRoomId: chatRoom.id) { [weak self] messages in
DispatchQueue.main.async {
self?.messages = messages
}
self?.scrollToBottom(delay: 0.15)
}
}
private func subscribeToNewMessages() {
...
if !newMessages.isEmpty {
self?.scrollToBottom(delay: 0)
}
}
func scrollToBottom(delay: TimeInterval) {
DispatchQueue.main.async {
self.shouldScrollToMessageId = self.messages.last?.id
}
}
func sendMessage() {
...
scrollToBottom(delay: 0)
}
}
Here, scrollToBottom is responsible for notifying the MessagesView that shouldScrollToMessageId's value changed and that it should scroll to the last message.
Any help will be much appreciated!!!
I am also writing an application with a chat on SwiftUI and I also have a lot of headaches with a bunch of ScrollView and LazyVStack.
In my case, I load messages from the CoreData and display them in a LazyVStack, so in my case, scrolling to the last message does not work, it seems to me simply because a bottom last view did not render, because rendering starts from the top.
Therefore, I came up with a solution with placing an invisible view at the bottom and assigned it a static ID, in my case -1:
VStack(spacing: 0) {
LazyVStack(spacing: 0) {
ForEach(messages) { message in
MessageRowView(viewWidth: wholeViewProxy.size.width, message: message)
.equatable()
}
}
Color.clear.id(-1)
.padding(.bottom, inputViewHeight)
}
I call scroll to this view:
.onAppear {
scrollTo(messageID: -1, animation: nil, scrollReader: scrollReader)
}
And it works... but sometimes...
Sometimes it works correctly, sometimes it stops without scrolling a couple of screens to the end. Looks like LazyVStack is rendering the next views after the scrollTo has finished its work. Also, if add scrolling with some delay it may works better, but not always perfect.
I hope this can be a little helpful and if you find a stable solution I will be very happy if you share :)

swipeActions in a list: button can not be pressed

I'm trying to add a simple swipe action to my list. But for some reason the button can not be pressed. When I perform a full swipe it works though.
This is my code:
var listView: some View {
List {
ForEach(Array(debits.enumerated()), id: \.element) { index, debit in
HStack {
Text(debit.name)
.swipeActions(allowsFullSwipe: true) {
Button(action: {
print("Hi \(index)")
}, label: {
Image(systemName: "slider.horizontal.3")
})
}
Spacer()
Text(String(debit.value))
.frame(width: 70, alignment: .trailing)
Text("€")
Divider().padding(.leading, 5)
Toggle("", isOn: $debits[index].toggle)
.onChange(of: debit.toggle) { newValue in
calculateAvailable()
}
.frame(width: 50)
}
}
}.listStyle(.plain).lineLimit(1)
}
Like I said, a press on the button does not print anything but the full swipe does.
Move the .swipeActions modifier to the HStack containing the whole row:
List {
ForEach(Array(debits.enumerated()), id: \.element) { index, debit in
HStack {
Text(debit.name)
Spacer()
Text(String(debit.value))
.frame(width: 70, alignment: .trailing)
Text("€")
Divider().padding(.leading, 5)
Toggle("", isOn: $debits[index].toggle)
.onChange(of: debit.toggle) { newValue in
calculateAvailable()
}
.frame(width: 50)
}
.swipeActions(allowsFullSwipe: true) {
Button(action: {
print("Hi \(index)")
}, label: {
Image(systemName: "slider.horizontal.3")
})
}
}
}
Also, I would recommend adding a stable id to your Debit structure. Something like this:
struct Debit: Hashable, Identifiable {
var name: String
var value: Double
var toggle: Bool
let id = UUID()
}
and use that as your id:
ForEach(Array(debits.enumerated()), id: \.element.id) { index, debit in
If you weren’t enumerating to get the index, then you’d simply be able to iterate over debits since the items are Identifiable:
ForEach($debits) { $debit in
This is working fine, the HStack is your problem. Try to set the swipaction on the HStack
List {
ForEach(YourList, id: \.self) { debit in
Text(debit)
.swipeActions(allowsFullSwipe: true) {
Button(action: {
print("Hi \(index)")
}, label: {
Text("test")
})
}
}
}
enter code here
I just found the answer. I used this code to dismiss the keyboard when tapping somewhere. This prevented the button from being pressed.
var body: some View {
VStack {
my code
}
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
I also had this exact same problem and it turns out a containing view had an .onTapGesture handler which was capturing the presses on the button effectively making it only work after the full swipe, but not when tapped.

SwiftUI: fullScreenCover with no animation?

I have this view:
struct TheFullCover: View {
#State var showModal = false
var body: some View {
Button(action: {
showModal.toggle()
}) {
Text("Show Modal")
.padding()
.foregroundColor(.blue)
}
.background(Color(.white))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.red, lineWidth:1)
)
.fullScreenCover(isPresented: $showModal, onDismiss: {
}, content: {
VStack {
Text("Here I am")
TheFullCover()
}
})
}
}
Every time I press the Button, the modal screen comes up fullscreen. All works great.
Question:
How do I disable the slide up animation? I want the view to be presented immediately fullscreen without animating to it.
Is there a way to do that?
A possible solution is to disable views animation completely (and then, if needed, enable again in .onAppear of presenting content), like
Button(action: {
UIView.setAnimationsEnabled(false) // << here !!
showModal.toggle()
}) {
and then
}, content: {
VStack {
Text("Here I am")
TheFullCover()
}
.onAppear {
UIView.setAnimationsEnabled(true) // << here !!
}
})
Tested with Xcode 13 / iOS 15
AFAIK the proper to do it as of today is using transaction https://developer.apple.com/documentation/swiftui/transaction
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
showModal.toggle()
}
I also created a handy extension for this:
extension View {
func withoutAnimation(action: #escaping () -> Void) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
action()
}
}
}
which can be used like this:
withoutAnimation {
// do your thing
}
.fullScreenCover(isPresented: isPresented) {
content()
.background(TransparentBackground())
}
.transaction({ transaction in
transaction.disablesAnimations = true
})
this should work, based on #asamoylenko's answer
At the moment, I find it easier to use UIKit for presentation in SwiftUI.
someView
.onChange(of: isPresented) { _ in
if isPresented {
let vc = UIHostingController(rootView: MyView())
vc.modalPresentationStyle = .overFullScreen
UIApplication.shared.rootVC?.present(vc, animated: false)
} else {
UIApplication.shared.rootVC?.dismiss(animated: false)
}
}
Why not use an overlay instead?
.overlay {
if isLoading {
ZStack {
ProgressView()
}
.background(BackgroundCleanerView())
}
}

SwiftUI Picker onReceive() called every time the body is rendered

i am using a Picker to show a segmented control and wish to know when the picker value changes so i can perform a non-UI action. Using the proposed onReceive() modifier (as suggested here) does not work as it is called every time the body is rendered.
Here's the code i have:
struct PickerView: View {
#State private var weather = 0
#State private var showMessage = false
var body: some View {
VStack(spacing: 24) {
Picker(selection: $weather, label: Text("Weather")) {
Image(systemName: "sun.max.fill").tag(0)
Image(systemName: "cloud.sun.rain.fill").tag(1)
}
.pickerStyle(SegmentedPickerStyle())
.frame(width: 120, height: 48)
.onReceive([weather].publisher.first()) { connectionType in
print("connection type is: \(connectionType)")
}
Button(action: { self.showMessage.toggle() }) {
Text("Press Me")
}
if showMessage {
Text("Hello World")
}
}
}
}
The onReceive() block will get called any time the body is rendered, including the first time and any time the button (which toggles showing a message) is pressed.
Any ideas why this is happening and how i can only react to when the picker value is changed?
Here is possible solution instead of .onReceive
Picker(selection: Binding( // << proxy binding
get: { self.weather },
set: { self.weather = $0
print("connection type is: \($0)") // side-effect
})
, label: Text("Weather")) {
Image(systemName: "sun.max.fill").tag(0)
Image(systemName: "cloud.sun.rain.fill").tag(1)
}

SwiftUI - Respond to tap AND double tap with different actions

I need to respond to a single tap and double tap with different actions, but is SwiftUI the double tap gesture is interpreted as two single taps.
In swift you can use fail gesture, but no idea how to do it in SwiftUI.
Example:
.onTapGesture(count: 1) {
print("Single Tap!")
}
.onTapGesture(count: 2) {
print("Double Tap!")
}
TIA.
First one prevent second one to perform. So reverse the order of your code:
.onTapGesture(count: 2) {
print("Double Tap!")
}
.onTapGesture(count: 1) {
print("Single Tap!")
}
Update: Second solution
Since the above method reported not working in some situationis, you can try using gestures modifier instead:
.gesture(TapGesture(count: 2).onEnded {
print("double clicked")
})
.simultaneousGesture(TapGesture().onEnded {
print("single clicked")
})
Text("Tap Me!").gesture(
TapGesture(count: 2)
.onEnded({ print("Tapped Twice!”)})
.exclusively(before:
TapGesture()
.onEnded({print("Tapped Once!”) })
)
)
There does not seem to be a simple solution (yet), most of these answers trigger both actions.
The workaround I have discovered feels clumsy, but works smoothly:
struct hwsView: View {
var body: some View {
VStack {
Text("HWS")
.onTapGesture {
print("Text single tapped")
}
}
.highPriorityGesture(
TapGesture(count: 2)
.onEnded { _ in
print("hws double tapped")
}
)
}
}
This can be achieved with exclusively(before:). It allows for only one gesture to succeed.
.gesture(
TapGesture(count: 2).onEnded {
print("DOUBLE TAP")
}.exclusively(before: TapGesture(count: 1).onEnded {
print("SINGLE TAP")
})
)
Link to method documentation https://developer.apple.com/documentation/swiftui/gesture/exclusively(before:)
I had a need for double and triple taps to be recognised on an object but only trigger one of the action closures. I found that the exclusively() API didn't work for me on iOS 15. So, I created this little hack that does what I want.
Struct ContentView: View {
#State private var isTripleTap = false
var body: some View {
Text("Button")
.gesture(
TapGesture(count: 2).onEnded {
Task {
try? await Task.sleep(nanoseconds: 200_000_000)
if !isTripleTap {
print("Double Tap")
}
isTripleTap = false
}
}
.simultaneously(
with: TapGesture(count: 3).onEnded {
isTripleTap = true
print("Triple Tap")
}
)
)
}
}
You can also setup a count for TapGesture like so:
.simultaneousGesture(TapGesture(count: 2).onEnded {
// double tap logic
})
Per Apple Developer Documentation:
https://developer.apple.com/documentation/swiftui/tapgesture