Long press of NavigationView only work on the left part, not all the NavigationLink? - ios

Following is a NavigationView, the view pops to Destination2 when long press the NavigationLink and to Destination1 when normally tap it. But the right zone of the NavigationLink in the picture cannot be long pressed.
Does anyone know the reason? Thanks!
import SwiftUI
struct ContentView: View {
#State private var isLongPressed = false
#State var currentTag: Int?
let lyrics = ["OutNotWorkA", "OutNotWorkB", "OutNotWorkC"]
var body: some View {
NavigationView {
List {
ForEach(0..<lyrics.count) { index in
VStack{
HStack(alignment: .top) {
NavigationLink(destination: Group
{ if self.isLongPressed { Destination2() } else { Destination1() } }, tag: index, selection: self.$currentTag
) {
Text(self.lyrics[index])
}
}
}.simultaneousGesture(LongPressGesture().onEnded { _ in
print("Got Long Press")
self.currentTag = index
self.isLongPressed = true
})
.simultaneousGesture(TapGesture().onEnded{
print("Got Tap")
self.currentTag = index
self.isLongPressed = false
})
.onAppear(){
self.isLongPressed = false
}
}
}
}
}
}
struct Destination1: View {
var body: some View {
Text("Destination1")
}
}
struct Destination2: View {
var body: some View {
Text("Destination2")
}
}

Then how to handle the whole part?
Find below the fix
VStack{
HStack(alignment: .top) {
NavigationLink(destination: Group
{ if self.isLongPressed { Destination2() } else { Destination1() } }, tag: index, selection: self.$currentTag
) {
Text(self.lyrics[index])
}
}
}
.contentShape(Rectangle()) // << here !!
.simultaneousGesture(LongPressGesture().onEnded { _ in

LongPressGesture only works on the visualized part of the label.
The easiest way to handle this problem is a little workaround with a lot of spaces:
Text(self.lyrics[index]+" ")
Because only using spaces doesn't create a line break this makes no visual problems in your App.

Related

SwiftUI TabView - run code in subview after sequential taps

I am trying to implement the behavior in a TabView when the user taps the same tab multiple times, such as in the iOS AppStore app. First tap: switch to that view, second tap: pop to root, third tap: scroll to the top if needed.
The code below works fine for switching and didTap() is called for every tap.
import SwiftUI
enum Tab: String {
case one
case two
}
struct AppView: View {
#State private var activeTab = Tab.one
var body: some View {
TabView(selection: $activeTab.onChange(didTap)) {
One()
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
Two()
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
func didTap(to value: Tab) {
print(value) // this captures every tap
}
}
extension Binding {
func onChange(_ handler: #escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler(newValue)
}
)
}
}
What I am struggling with, is how to tell either One or Two that it was tapped for a second or third time? (How to pop and scroll is not the issue).
I have seen this: TabView, tabItem: running code on selection or adding an onTapGesture but it doesn't explain how to run code in one of the views.
Any suggestions?
You can record additional taps (of same value) in an array. The array count gives you the number of taps on the same Tab.
EDIT: now with explicit subview struct.
struct ContentView: View {
#State private var activeTab = Tab.one
#State private var tapState: [Tab] = [Tab.one] // because .one is default
var body: some View {
TabView(selection: $activeTab.onChange(didTap)) {
SubView(title: "One", tapCount: tapState.count)
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
SubView(title: "Two", tapCount: tapState.count)
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
func didTap(to value: Tab) {
print(value) // this captures every tap
if tapState.last == value {
tapState.append(value) // apped next tap if same value
print("tapped \(tapState.count) times")
} else {
tapState = [value] // reset tap state to new tab selection
}
}
}
struct SubView: View {
let title: String
let tapCount: Int
var body: some View {
VStack {
Text("Subview \(title)").font(.title)
Text("tapped \(tapCount) times")
}
}
}
Although the answer by #ChrisR did answer my question, I couldn't figure out the next step, i.e. the logic when to pop-to-root or scroll-to-the-top based on the number of taps for a SubView. After lots of reading and trial and error, I recently came across this article: https://notificare.com/blog/2022/11/25/a-better-tabview-in-swiftui/
Inspired by this article, but with some modifications, I came up with the following which does exactly what I was looking for.
The two main changes are:
An EmptyView with an id is added as the first (but invisible) row in the List to be used as an anchor by proxy.scrollTo().
Instead of the global #StateObject var appState that stores the navigation paths for the subviews, I added the paths as separate #State properties. This avoids the Update NavigationAuthority bound path tried to update multiple times per frame. warning.
Hopefully this is helpful for someone.
enum Tab: String {
case one
case two
}
struct ContentView: View {
#State var selectedTab = Tab.one
#State var oneNavigationPath = NavigationPath()
#State var twoNavigationPath = NavigationPath()
var body: some View {
ScrollViewReader { proxy in
TabView(selection: tabViewSelectionBinding(proxy: proxy)) {
SubView(title: "One", path: $oneNavigationPath)
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
SubView(title: "Two", path: $twoNavigationPath)
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
}
private func tabViewSelectionBinding(proxy: ScrollViewProxy) -> Binding<Tab> {
Binding<Tab>(
get: { selectedTab },
set: { newValue in
if selectedTab == newValue {
switch selectedTab {
case .one:
if oneNavigationPath.isEmpty {
withAnimation {
proxy.scrollTo(Tab.one, anchor: .bottom)
}
} else {
withAnimation {
oneNavigationPath = NavigationPath()
}
}
case .two:
if twoNavigationPath.isEmpty {
withAnimation {
proxy.scrollTo(Tab.two, anchor: .bottom)
}
} else {
withAnimation {
twoNavigationPath = NavigationPath()
}
}
}
}
selectedTab = newValue
}
)
}
}
struct SubView: View {
let title: String
let items = Array(1 ... 100)
#Binding var path: NavigationPath
var body: some View {
NavigationStack(path: $path) {
List {
EmptyView()
.id(Tab(rawValue: title.lowercased()))
ForEach(items, id: \.self) { item in
NavigationLink(value: item) {
Text("Item \(item)")
}
}
}
.navigationTitle(title)
.navigationDestination(for: Int.self) { item in
Text("Item \(item)")
}
}
}
}

How to make ScrollViewReader scroll to top of List?

I have List within a TabView and allowing the user to scroll to the top when they double tap a tab. I'm using a ScrollViewReader to scroll to a specific anchor. However, it is not fully scrolling to the top of the list because of the navigation title, see the title overlapping the content:
I'm using the technique from this blog post for more context. Below is a working sample:
struct ContentView: View {
#State private var _selectedTab: SelectedTab = .one
#State private var tabbedTwice = false
var selectedTab: Binding<SelectedTab> {
Binding(
get: { _selectedTab },
set: {
if $0 == _selectedTab {
tabbedTwice = true
}
_selectedTab = $0
}
)
}
enum SelectedTab: String {
case one
case two
}
var body: some View {
ScrollViewReader { proxy in
TabView(selection: selectedTab) {
NavigationView {
List {
Section {
ForEach(1...50, id: \.self) { index in
Text("Item \(index.formatted())")
}
}
.id(SelectedTab.one.rawValue)
Section {
Text("Section 2")
}
}
.navigationTitle("First")
}
.tabItem {
Label("One", systemImage: "clock.arrow.circlepath")
}
.tag(SelectedTab.one)
NavigationView {
List {
Section(header: Text("Header").id(SelectedTab.two.rawValue)) {
ForEach(50...100, id: \.self) { index in
Text("Item \(index.formatted())")
}
}
Section {
Text("Section 2")
}
}
.navigationTitle("Second")
}
.tabItem {
Label("Two", systemImage: "list.bullet")
}
.tag(SelectedTab.two)
}
.onChange(of: tabbedTwice) {
guard $0 else { return }
withAnimation { proxy.scrollTo(_selectedTab.rawValue, anchor: .top) }
tabbedTwice = false
}
}
}
}
Is there a better place to put the anchor identifier? I tried putting on the first section and also the section header which worked better but still not scrolling to the very top. How can this be achieved?

SwiftUI Form Cell losing selection UI when drilling into details?

I have the following code:
enum SelectedDetails:Int, CaseIterable {
case d0
case d1
}
struct CellSelectionTestView : View {
#State var selection:SelectedDetails? = .d0
var body: some View {
NavigationView {
Form {
Section(header: Text("Section 0")) {
NavigationLink(destination: D0DetailsView(),
tag: .d0,
selection: $selection) {
D0CellView().frame(height: 80)
}
NavigationLink(destination: D1CellView(),
tag: .d1,
selection: $selection) {
D1CellView().frame(height: 80)
}
}
}
}
}
}
struct D0CellView: View {
var body: some View {
Text("D0")
}
}
struct D0DetailsView: View {
var body: some View {
VStack {
List {
ForEach(0..<10) { n in
NavigationLink.init(destination: OptionsDetailsView(index:n)) {
Text("show \(n) details")
}
}
}
.refreshable {
}
}
}
}
struct OptionsDetailsView: View {
let index:Int
var body: some View {
Text("OptionsDetailsView \(index)")
}
}
struct D1CellView: View {
var body: some View {
Text("D1")
}
}
When I tap on D0 cell, it shows this:
D0 cell correctly shows the selected state UI.
Then I tap on one of the show <n> details cells and the selection goes away:
How do I keep D0 cell selected UI stated active until I tap on another cell like D1 for example regardless of what I do in the details view to the right? I need to keep UI context as the user does what is needed within the details shown when D0 is tapped. Why is that selection going away if I didn't even tap on D1?
Strange, but it seems like NavigationView can only keep one selection. I found a workaround by integrating a second NavigationView with .stacked style in your child view:
struct D0DetailsView: View {
var body: some View {
NavigationView {
VStack {
List {
ForEach(0..<10) { n in
NavigationLink {
OptionsDetailsView(index:n)
} label: {
Text("show \(n) details")
}
}
}
.refreshable {
}
}
}
.navigationViewStyle(.stack)
}
}
Another approach: save the last active selection and set the select background color manually:
struct CellSelectionTestView : View {
#State private var selection: SelectedDetails? = .d0
#State private var selectionSaved: SelectedDetails = .d0
var body: some View {
NavigationView {
Form {
Section(header: Text("Section 0")) {
NavigationLink(tag: .d0, selection: $selection) {
D0DetailsView()
} label: {
D0CellView().frame(height: 80)
}
.listRowBackground(selectionSaved == .d0 ? Color.gray : Color.clear)
NavigationLink(tag: .d1, selection: $selection) {
D1CellView()
} label:{
D1CellView().frame(height: 80)
}
.listRowBackground(selectionSaved == .d1 ? Color.gray : Color.clear)
}
}
}
.onChange(of: selection) { newValue in
if selection != nil { selectionSaved = selection! }
}
}
}

SwiftUI LazyVGrid NavigationLink has unusual animation on return

I have a NavigationLink within a LazyVGrid and am getting this animation on return from the details view. Starting at about 3.5 seconds into that video, there is an animation I wasn't expecting. There are gaps introduced between the cells and I don't like the way it looks.
Here is the code for the screen with the LazyVGrid:
import Foundation
import SwiftUI
import SFSafeSymbols
import CustomModalView
struct AlbumItemsScreen: View {
#ObservedObject var viewModel:AlbumItemsViewModel
let gridItemLayout = [GridItem(.adaptive(minimum: 50), spacing: 20)]
#State var object: ServerObjectModel?
#State var enableNavLink: Bool = false
var body: some View {
RefreshableScrollView(refreshing: $viewModel.loading) {
LazyVGrid(columns: gridItemLayout) {
ForEach(viewModel.objects, id: \.fileGroupUUID) { item in
AlbumItemsScreenCell(object: item)
.onTapGesture {
object = item
viewModel.showCellDetails = true
}
// Without this conditional, "spacer" cells show up in the grid.
if viewModel.showCellDetails, let object = object {
// The `NavigationLink` works here because the `MenuNavBar` contains a `NavigationView`.
NavigationLink(
destination:
// If I just use `item` directly in this-- oddly, it doesn't reference the same object as for `AlbumItemsScreenCell` above.
ObjectDetailsView(object: object),
isActive:
$viewModel.showCellDetails) {
}
} // end if
} // end ForEach
} // end LazyVGrid
.padding(10)
}
.alert(isPresented: $viewModel.presentAlert, content: {
let message:String = viewModel.alertMessage
viewModel.alertMessage = nil
return Alert(title: Text(message))
})
.navigationBarTitle("Album Contents")
.navigationBarItems(trailing:
AlbumItemsScreenNavButtons(viewModel: viewModel)
)
.disabled(viewModel.addNewItem)
.modal(isPresented: $viewModel.addNewItem) {
AddItemModal(viewModel: viewModel)
.padding(20)
}
.modalStyle(DefaultModalStyle())
.onDisappear {
// I'm having a problem with the modal possibly being presented, the user navigating away, coming back and the modal still being present.
// See also https://github.com/jankaltoun/CustomModalView/issues/1
if viewModel.addNewItem == true {
viewModel.addNewItem = false
}
}
}
}
private struct AlbumItemsScreenNavButtons: View {
#ObservedObject var viewModel:AlbumItemsViewModel
var body: some View {
HStack(spacing: 0) {
Button(
action: {
viewModel.sync()
},
label: {
SFSymbolNavBar(symbol: .goforward)
}
)
Button(
action: {
viewModel.startNewAddItem()
},
label: {
SFSymbolNavBar(symbol: .plusCircle)
}
)
}
}
}
(see also https://github.com/SyncServerII/Neebla/blob/main/Neebla/UI/Screens/Album%20Items/AlbumItemsScreen.swift).
Here is the code for the details view:
import Foundation
import SwiftUI
import SFSafeSymbols
struct ObjectDetailsView: View {
let object:ServerObjectModel
var model:MessagesViewModel?
#State var showComments = false
init(object:ServerObjectModel) {
self.object = object
model = MessagesViewModel(object: object)
}
var body: some View {
VStack {
AnyLargeMedia(object: object)
.onTapGesture {
if let _ = model {
showComments = true
}
}
Spacer()
}
.navigationBarItems(trailing:
Button(
action: {
showComments = true
},
label: {
SFSymbolNavBar(symbol: .message)
}
)
.enabled(model != nil)
)
.sheet(isPresented: $showComments) {
if let model = model {
CommentsView(model: model)
}
else {
// Should never get here. Should never have showComments == true when model is nil.
EmptyView()
}
}
}
}
(see also https://github.com/SyncServerII/Neebla/blob/main/Neebla/UI/Screens/Object%20Details/ObjectDetailsView.swift).
I've tried the strategy indicated here https://developer.apple.com/forums/thread/125937 with this:
NavigationLink(
destination:
// If I just use `item` directly in this-- oddly, it doesn't reference the same object as for `AlbumItemsScreenCell` above.
ObjectDetailsView(object: object),
isActive:
$viewModel.showCellDetails) {
EmptyView()
}
.frame(width: 0, height: 0)
.disabled(true)
but the same effect occurs.
Well, it helped to focus my attention on the problem by writing up this question. I've come up with a solution. I took the NavigationLink out of the scrollview and LazyVGrid:
import Foundation
import SwiftUI
import SFSafeSymbols
import CustomModalView
struct AlbumItemsScreen: View {
#ObservedObject var viewModel:AlbumItemsViewModel
let gridItemLayout = [GridItem(.adaptive(minimum: 50), spacing: 20)]
#State var object: ServerObjectModel?
var body: some View {
VStack {
RefreshableScrollView(refreshing: $viewModel.loading) {
LazyVGrid(columns: gridItemLayout) {
ForEach(viewModel.objects, id: \.fileGroupUUID) { item in
AlbumItemsScreenCell(object: item)
.onTapGesture {
object = item
viewModel.showCellDetails = true
}
} // end ForEach
} // end LazyVGrid
.padding(10)
}
if let object = object {
// The `NavigationLink` works here because the `MenuNavBar` contains a `NavigationView`.
NavigationLink(
destination:
ObjectDetailsView(object: object),
isActive:
$viewModel.showCellDetails) {
EmptyView()
}
.frame(width: 0, height: 0)
.disabled(true)
} // end if
}
.alert(isPresented: $viewModel.presentAlert, content: {
let message:String = viewModel.alertMessage
viewModel.alertMessage = nil
return Alert(title: Text(message))
})
.navigationBarTitle("Album Contents")
.navigationBarItems(trailing:
AlbumItemsScreenNavButtons(viewModel: viewModel)
)
.disabled(viewModel.addNewItem)
.modal(isPresented: $viewModel.addNewItem) {
AddItemModal(viewModel: viewModel)
.padding(20)
}
.modalStyle(DefaultModalStyle())
.onDisappear {
// I'm having a problem with the modal possibly being presented, the user navigating away, coming back and the modal still being present.
// See also https://github.com/jankaltoun/CustomModalView/issues/1
if viewModel.addNewItem == true {
viewModel.addNewItem = false
}
}
}
}
private struct AlbumItemsScreenNavButtons: View {
#ObservedObject var viewModel:AlbumItemsViewModel
var body: some View {
HStack(spacing: 0) {
Button(
action: {
viewModel.sync()
},
label: {
SFSymbolNavBar(symbol: .goforward)
}
)
Button(
action: {
viewModel.startNewAddItem()
},
label: {
SFSymbolNavBar(symbol: .plusCircle)
}
)
}
}
}

How to change NavigationView's destination in SwiftUI?

The need is to go to View1 by default and when onLongPressGesture to View2. #State is used to make it. And the code is as following:
#State var showingView2 = false
if self.showingView2 {
NavigationLink(destination: View2())
{ Cell(name: View2.name)
.onLongPressGesture(minimumDuration: 1) {
self.showingView2 = true
}
}
} else {
NavigationLink(destination: View1())
{ Cell(name: View1.name)
.onLongPressGesture(minimumDuration: 1) {
self.showingView2 = true
}
}
}
For the simplicity, new button cannot be added in the UI. I know the code cannot run, but I do not know how to fix it.
Any suggestion will help me. Thanks!
=========================================
Updated
Thanks for #Asperi!
But in my project there is ForEach in NavigationView, in which case the answer does not work. The code is following:
import SwiftUI
struct ContentView: View {
#State private var isLongPressed = false
let lyrics = ["a", "b", "c"]
var body: some View {
NavigationView {
List() {
ForEach(0..<lyrics.count) { (index) in
NavigationLink(destination:
Group {
if self.isLongPressed { Destination2() }
else { Destination1() }
})
{ Text(self.lyrics[index]) }
.simultaneousGesture(LongPressGesture(minimumDuration: 1).onEnded { flag in
self.isLongPressed.toggle()
})
}
}
}
}
}
struct Destination1: View {
var body: some View {
Text("Destination1")
}
}
struct Destination2: View {
var body: some View {
Text("Destination2")
}
}
Then how to make it in this case? And I do not want to change the short tap or long tap's function, just short tap to View1 and long tap to View2. Thanks!
Here is a demo of possible approach. Tested with Xcode 11.4 / iOS 13.4
struct TestAlternateDestinations: View {
#State private var isLongPressed = false
var body: some View {
NavigationView {
NavigationLink(destination: Group {
if isLongPressed { Destination2() }
else { Destination1() }}) {
Text(self.isLongPressed ? "Link2" : "Link1")
}
.simultaneousGesture(LongPressGesture(minimumDuration: 1).onEnded { flag in
self.isLongPressed.toggle()
})
}
}
}
struct Destination1: View {
var body: some View {
Text("Destination1")
}
}
struct Destination2: View {
var body: some View {
Text("Destination2")
}
}
Here is the final answer based on #Asperi.
And there is still a little bug. If not add many space in the NavigationLink's name, the long tap only work when you tap on the "a" "b" "c". On other space of the NavigationLink out of the "a" "b" "c", all tap is short tap.
import SwiftUI
struct ContentView: View {
#State private var isLongPressed = false
#State var currentTag: Int?
let lyrics = ["a", "b", "c"]
var body: some View {
NavigationView {
List {
ForEach(0..<lyrics.count) { index in
VStack{
HStack(alignment: .top) {
NavigationLink(destination: Group
{ if self.isLongPressed { Destination2() } else { Destination1() } }, tag: index, selection: self.$currentTag
) {
// This is still a little bug. If not add many space, the long tap only work when you tap on the "a" "b" "c". On other space of the NavigationLink out of the "a" "b" "c", all tap is short tap.
Text(self.lyrics[index] + " ")
}
}
}.simultaneousGesture(LongPressGesture().onEnded { _ in
print("Got Long Press")
self.currentTag = index
self.isLongPressed = true
})
.simultaneousGesture(TapGesture().onEnded{
print("Got Tap")
self.currentTag = index
self.isLongPressed = false
})
.onAppear(){
self.isLongPressed = false
}
}
}
}
}
}
struct Destination1: View {
var body: some View {
Text("Destination1")
}
}
struct Destination2: View {
var body: some View {
Text("Destination2")
}
}

Resources