SwiftUI - Scroll a list to a specific element controlled by external variable - swift

I have a List populated by Core Data, like this:
#EnvironmentObject var globalVariables : GlobalVariables
#Environment(\.managedObjectContext) private var coreDataContext
#FetchRequest(fetchRequest: Expressao.getAllItemsRequest())
private var allItems: FetchedResults<Expressao>
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(allItems,
id: \.self) { item in
Text(item.term!.lowercased())
.id(allItems.firstIndex(of:item))
.listRowBackground(
Group {
if (globalVariables.selectedItem == nil) {
Color(UIColor.clear)
} else if item == globalVariables.selectedItem {
Color.orange.mask(RoundedRectangle(cornerRadius: 20))
} else {
nextAlternatedColor(item:item)
}
}
}
}
}
}
}
Every time a row is selected it changes color to orange. So, you see that the color is controlled by an external variable located in globalVariables.selectedItem.
I want to be able to make the list scroll to that element on globalVariables.selectedItem automatically.
How do I do that with ScrollViewReader?
Any ideas?

Here is a demo of possible approach - scrollTo can be used only in closure, so the idea is to create some background view depending on row to be scrolled to (this can be achieved with .id) and attach put .scrollTo in .onAppear of that view.
Tested with Xcode 12 / iOS 14.
struct DemoView: View {
#State private var row = 0
var body: some View {
VStack {
// button here is generator of external selection
Button("Go \(row)") { row = Int.random(in: 0..<50) }
ScrollViewReader { proxy in
List {
ForEach(0..<50) { item in
Text("Item \(item)")
.id(item)
}
}
.background( // << start !!
Color.clear
.onAppear {
withAnimation {
proxy.scrollTo(row, anchor: .top)
}
}.id(row)
) // >> end !!
}
}
}
}

Related

SwiftUI: NavigationView detail view pops backstack when previous's view List changes

I have a List of ids and scores in my first screen.
In the detail screen I click and call a callback that adds to the score and resorts the List by the score.
When I do this with an item at the top of the list, nothing happens. (Good)
When I do this with an item at the bottom of the list, the navigation view pops the backstack and lands me back on the first page. (Bad)
import SwiftUI
class IdAndScoreItem {
var id: Int
var score: Int
init(id: Int, score: Int) {
self.id = id
self.score = score
}
}
#main
struct CrazyBackStackProblemApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
ListView()
}
.navigationViewStyle(.stack)
}
}
}
struct ListView: View {
#State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
func addScoreAndSort(item: IdAndScoreItem) {
items = items
.map {
if($0.id == item.id) { $0.score += 1 }
return $0
}
.sorted {
$0.score > $1.score
}
}
var body: some View {
List(items, id: \.id) { item in
NavigationLink {
ScoreClickerView(
onClick: { addScoreAndSort(item: item) }
)
} label: {
Text("id: \(item.id) score:\(item.score)")
}
}
}
}
struct ScoreClickerView: View {
var onClick: () -> Void
var body: some View {
Text("tap me to increase the score")
.onTapGesture {
onClick()
}
}
}
How can I make it so I reorder the list on the detail page, and that's reflected on the list page, but the navigation stack isn't popped (when I'm doing it on a list item at the bottom of the list). I tried added navigationStyle(.stack) to no avail.
Thanks for any and all help!
Resort changes order of IDs making list recreate content that leads to current NavigationLinks destroying, so navigating back.
A possible solution is to separate link from content - it can be done with introducing something like selection (tapped row) and one navigation link activated with that selection.
Tested with Xcode 14 / iOS 16
#State private var selectedItem: IdAndScoreItem? // selection !!
var isNavigate: Binding<Bool> { // link activator !!
Binding(get: { selectedItem != nil}, set: { _ in selectedItem = nil })
}
var body: some View {
List(items, id: \.id) { item in
Text("id: \(item.id) score:\(item.score)") // tappable row
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
selectedItem = item
}
}
.background(
NavigationLink(isActive: isNavigate) { // one link !!
ScoreClickerView {
if let item = selectedItem {
addScoreAndSort(item: item)
}
}
} label: {
EmptyView()
}
)
}
Do your sorting on onAppear. No need to sort on each click.
struct ListView: View {
#State var items = (1...50).map { IdAndScoreItem(id: $0, score: 0) }
func addScoreAndSort(item: IdAndScoreItem) {
item.score += 1
}
var body: some View {
List(items, id: \.id) { item in
NavigationLink {
ScoreClickerView(
onClick: { addScoreAndSort(item: item) }
)
} label: {
Text("id: \(item.id) score:\(item.score)")
}
}.onAppear { // <==== Here
items = items
.sorted {
$0.score > $1.score
}
}
}
}
Note : No need to use map here. since you are using class so it will update with reference.

SwiftUI NavigationLink style within ScrollView

I have a screen where I'm trying to display a list of NavigationLink and a grid of items (using LazyVGrid). I first tried putting everything in a List, like this:
List() {
ForEach(items) { item in
NavigationLink(destination: MyDestination()) {
Text("Navigation link text")
}
}
LazyVGrid(columns: columns) {
ForEach(gridItems) { gridItem in
MyGridItem()
}
}
}
However, it seems that putting a LazyVGrid in a List doesn't load the items in the grid lazily, it loads them all at once. So I replaced the List with a ScrollView and it works properly. However, I do want to keep the style of the NavigationLink that is shown when they are in a List. Basically what this looks like https://www.simpleswiftguide.com/wp-content/uploads/2019/10/Screen-Shot-2019-10-05-at-3.00.34-PM.png instead of https://miro.medium.com/max/800/1*LT7ZwIaidXrMuR6pu1Jvgg.png.
How can this be achieved? Or is there a way to put a LazyVGrid in a List and still have it load lazily?
Take a loot at this, the List is already a built-in Lazy load scroll view, you can check that is lazy on the onAppear event
import SwiftUI
struct item:Identifiable {
let id = UUID()
let value: String
}
struct listTest: View {
#State var items: [item]
init () {
var newItems = [item]()
for i in 1...50 {
newItems.append(item(value: "ITEM # \(i)"))
}
self.items = newItems
}
var body: some View {
NavigationView {
List($items) { $item in
NavigationLink(destination: MyDestination(value: item.value)) {
Text("Navigation link text")
.onAppear{
print("IM ITEM: \(item.value)")
}
.id(UUID())
}
}
}
}
}
struct MyDestination: View {
let value: String
var body: some View {
ZStack{
Text("HI A DETAIL: \(value)")
}
}
}

NavigationLink with isActive creates unusual scrolling behavior in macOS SwiftUI app

I have a macOS app with a 3-column view. In the first column, there is a List of items that is lengthy -- perhaps a couple hundred items.
If my NavigationLink for each item contains a isActive parameter, when clicking on the NavigationLinks, I get unpredictable/unwanted scrolling behavior on the list.
For example, if I scroll down to Item 100 and click on it, the List may decide to scroll back up to Item 35 or so (where the active NavigationLink is out of the frame). The behavior seems somewhat non-deterministic -- it doesn't always scroll to the same place. It seems less likely to scroll to an odd location if I scroll through the list to my desired item and then wait for the system scroll bars to disappear before clicking on the NavigationLink, but it doesn't make the problem disappear completely.
If I remove the isActive parameter, the scroll position is maintained when clicking on the NavigationLinks.
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue { activeItem = item }
}
}
var body: some View {
List(items) { item in
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item) //<-- Remove this line and everything works as expected
) {
Text(item.name)
}
}.listStyle(SidebarListStyle())
}
}
struct InnerSidebar : View {
#State private var items = Array(0...100).map { Item(name: "Inner item \($0)") }
var body: some View {
List(items) { item in
NavigationLink(destination: Text("Detail")) {
Text(item.name)
}
}
}
}
I would like to keep isActive, as I have some programatic navigation that I'd like to be able to do that depends on it. For example:
Button("Go to item 10") {
activeItem = items[10]
}
Is there any way to use isActive on the NavigationLink and avoid the unpredictable scrolling behavior?
(Built and tested with macOS 11.3 and Xcode 13.0)
The observed effect is because body of your main view is refreshed and all internals rebuilt. To avoid this we can separate sensitive part of view hierarchy into standalone view, so SwiftUI engine see that dependency not changed and view should not be updated.
Here is a fixed parts:
struct SidebarList : View {
#State private var items = Array(0...300).map { Item(name: "Item \($0)") }
#State private var activeItem : Item?
var body: some View {
List(items) {
SidebarRowView(item: $0, activeItem: $activeItem) // << here !!
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: InnerSidebar(),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
Tested with Xcode 13 / macOS 11.6

SwiftUI TabView: how to detect click on a tab?

I would like to run a function each time a tab is tapped.
On the code below (by using onTapGesture) when I tap on a new tab, myFunction is called, but the tabview is not changed.
struct DetailView: View {
var model: MyModel
#State var selectedTab = 1
var body: some View {
TabView(selection: $selectedTab) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
.onTapGesture {
model.myFunction(item: selectedTab)
}
}
}
How can I get both things:
the tabview being normally displayed
my function being called
As of iOS 14 you can use onChange to execute code when a state variable changes. You can replace your tap gesture with this:
.onChange(of: selectedTab) { newValue in
model.myFunction(item: newValue)
}
If you don't want to be restricted to iOS 14 you can find additional options here: How can I run an action when a state changes?
The above answers work well except in one condition. If you are present in the same tab .onChange() won't be called. the better way is by creating an extension to binding
extension Binding {
func onUpdate(_ closure: #escaping () -> Void) -> Binding<Value> {
Binding(get: {
wrappedValue
}, set: { newValue in
wrappedValue = newValue
closure()
})
}
}
the usage will be like this
TabView(selection: $selectedTab.onUpdate{ model.myFunction(item: selectedTab) }) {
Text("Graphs").tabItem{Text("Graphs")}
.tag(1)
Text("Days").tabItem{Text("Days")}
.tag(2)
Text("Summary").tabItem{Text("Summary")}
.tag(3)
}
Here is possible approach. For TabView it gives the same behaviour as tapping to the another tab and back, so gives persistent look & feel:
Full module code:
import SwiftUI
struct TestPopToRootInTab: View {
#State private var selection = 0
#State private var resetNavigationID = UUID()
var body: some View {
let selectable = Binding( // << proxy binding to catch tab tap
get: { self.selection },
set: { self.selection = $0
// set new ID to recreate NavigationView, so put it
// in root state, same as is on change tab and back
self.resetNavigationID = UUID()
})
return TabView(selection: selectable) {
self.tab1()
.tabItem {
Image(systemName: "1.circle")
}.tag(0)
self.tab2()
.tabItem {
Image(systemName: "2.circle")
}.tag(1)
}
}
private func tab1() -> some View {
NavigationView {
NavigationLink(destination: TabChildView()) {
Text("Tab1 - Initial")
}
}.id(self.resetNavigationID) // << making id modifiable
}
private func tab2() -> some View {
Text("Tab2")
}
}
struct TabChildView: View {
var number = 1
var body: some View {
NavigationLink("Child \(number)",
destination: TabChildView(number: number + 1))
}
}
struct TestPopToRootInTab_Previews: PreviewProvider {
static var previews: some View {
TestPopToRootInTab()
}
}

Picker Row not getting deselected in Form (SwiftUI, Xcode 12 beta 4)

I am creating a picker in a form in SwiftUI. On selection of a new element in picker, when the view pulls back to the form, the picker row isn't getting deselected. Here is a screenshot of what I mean, the picker row remains grayed out like this.
I read some previous answers which say that this was a bug in Xcode 11.3, however, I'm running Xcode 12 beta 4 and am not sure if this is still a bug.
This is how I'm creating the picker:
struct SettingsView: View {
#State private var currentSelection = 1
var body: some View {
NavigationView {
Form {
Section {
Picker("Test 2", selection: $currentSelection) {
ForEach(1 ...< 100) { i in
Text(String(i)).tag(i)
}
}
}
}
}
}
}
Here is my ContentView, from which I am presenting SettingsView:
enum ActiveSheet: Identifiable {
case photoPicker, settings
var id: Int {
self.hashValue
}
}
struct ContentView: View {
#State var activeSheet: ActiveSheet?
var body: some View {
NavigationView {
VStack {
Text("Hello world")
Button("Select Photo") {
self.activeSheet = .photoPicker
}
}
.navigationBarTitle(Text("Title"), displayMode: .inline)
.navigationBarItems(trailing: Button(action: {
self.activeSheet = .settings
}, label: {
Image(systemName: "gear")
.imageScale(.large)
}))
}
.sheet(item: $activeSheet) { item in
if item == .photoPicker {
ImagePicker(selectedImage: $image, sourceType: .photoLibrary)
} else {
SettingsView()
}
}
}
}
Edit: I created a brand new project, this is the only code:
import SwiftUI
struct ContentView: View {
#State private var currentSelection = 0
var body: some View {
NavigationView {
Form {
Section {
Picker("Test Picker", selection: $currentSelection) {
ForEach(0 ..< 100) { i in
Text(String(i)).tag(i)
}
}
}
}.navigationBarTitle(Text("Test"))
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The issue still persists.
(For the record I am on Big Sur, Xcode 12.2 and this issue is still there.)
After few tests it appears that this behaviour is due to multiple lists inside the same View. You can see that the first "List" (i.e. UITableView) gets its selected row deselected when the navigation stack is popped back to the view, but this doesn't work for the lists after the first one. So clearly this is a bug (in SwiftUI?!).
The only way (and somehow a crappy way) I have found to circumvent this bug is to have the Introspect Library get the tableView of interest (in this case the one displaying the Form) and do some housekeeping like this:
import Introspect
...
public class Weak<T: AnyObject> {
public weak var value: T?
public init(_ value: T? = nil) {
self.value = value
}
}
private var listTableView = Weak<UITableView>()
var body: Some View {
NavigationView {
VStack {
List {
...
}
Form {
Picker(...) {
...
}
}
.introspectTableView { listTableView.value = $0 }
.onAppear() {
if let tableView = listTableView.value, let indexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: indexPath, animated: true)
}
}
}
It works... but with no animation though (anyone?).