How to make ScrollViewReader scroll to top of List? - ios

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?

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)")
}
}
}
}

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 - Section style difference when embedding List within a VStack

It seems that there is a difference in showing a List Section header when you embed the List in a VStack. Does anybody know why this happens?
struct ContentView: View {
#State var toggle: Bool = false
let items: [String] = ["Apple", "Pear", "Banana"]
var body: some View {
NavigationView {
if toggle {
VStack {
list
}
} else {
list
}
}
}
var list: some View {
List {
Section {
ForEach(items, id: \.self) { item in
Text(item)
}
} header: {
Text("Fruit")
}
}
.navigationTitle(toggle ? "VStack" : "No VStack")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
toggle.toggle()
} label: {
Text("Toggle")
}
}
}
}
}

Swiftui how to add a view above a tabview?

The requirement is to display a banner above the tabbar. So that the banner does not disappear when the tabs are changed? How can I achieve it?
I can think of starting points, to help you see ways to approach this, but I don't think either idea really meets your requirements. It will depend on the details of whether the banner is permanent, where its content comes from, etc.
The first idea:
struct BannerView : View {
var text : String
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
Text(text)
Spacer()
}.background(Color.orange)
}
}
}
Then you can include this in a ZStack along with your tabs:
TabView {
ZStack {
Text("Tab 1")
BannerView("BANNER")
}.tabItem { Text("Home") }
ZStack {
Text("Tab 2")
BannerView("BANNER")
}.tabItem { Text("History") }
}
The second idea uses the BannerView from the first idea, but in a slightly cleaner way, still not great:
struct TabWrapperWithOptionalBanner<Content> : View where Content : View {
var showBanner : Bool
var content : Content
init(showBanner : Bool, #ViewBuilder content: () -> Content) {
self.showBanner = showBanner
self.content = content()
}
var body: some View {
ZStack {
content
if showBanner {
BannerView(text: "BANNER")
}
}
}
}
then your ContentView looks like this:
TabView {
TabWrapperWithOptionalBanner(showBanner: showBanner) {
Text("Tab 1")
}.tabItem { Text("Home") }
TabWrapperWithOptionalBanner(showBanner: showBanner) {
Text("Tab 2")
}.tabItem { Text("History") }
}
Update Try a pair of TabViews bound to the same State:
struct BanneredTabView: View {
#State private var selected = panels.one
var body: some View {
VStack {
TabView(selection: $selected) {
panels.one.label.tag(panels.one)
panels.two.label.tag(panels.two)
panels.three.label.tag(panels.three)
}
.tabViewStyle(PageTabViewStyle())
Text("Banner")
.frame(height: 40, alignment: .top)
TabView(selection: $selected) {
ForEach(panels.allCases) { panel in
Text("").tabItem {
panel.label
}
.tag(panel)
}
}
.frame(height: 30)
}
}
enum panels : Int, CaseIterable, Identifiable {
case one = 1
case two = 2
case three = 3
var label : some View {
switch self {
case .one:
return Label("Tab One", systemImage: "1.circle")
case .two:
return Label("Tab Two", systemImage: "2.square")
case .three:
return Label("Tab Three", systemImage: "asterisk.circle")
}
}
// so the enum can be identified when enumerated
var id : Int { self.rawValue }
}
}

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

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.

Resources