I am playing with LazyVGrid in my beta version of Xcode, and I'm trying to make a vertical ScrollView show up with two columns instead of one. I have this view set up to include a ScrollView that checks if iOS 14 is available, and if so, renders the view as a two column grid (I put the columns inside this instance of LazyVGrid rather than as an external property because I didn't want to have to mark the whole View with the #available property - although maybe I should).
However, when I preview the view, it shows up with only one column. Is there anything in my setup that might be causing my view to only show up with one column instead of two?
Related - is there an easy way to preview in Xcode with different builds that do/don't include iOS 14? (In case my issue is that my preview version doesn't have iOS 14).
Here is my view for reference:
struct PackPage: View {
#Binding var isPresented: Bool
var allPacks: [Pack] = [samplePack, couplesPack, roadTripPack, familyPack]
var purchasedPacks: [Pack] = [samplePack]
var unpurchasedPacks: [Pack] = [couplesPack, roadTripPack, familyPack]
let userDefaults = UserDefaults.standard
#State private var action: Int? = 0
#State private var linkLock: Pack? = samplePack
private func isPurchased(pack: Pack, allPacks: [Pack]) -> Bool {
if allPacks.contains(pack) {
return true
} else {
return false
}
}
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: PurchasePage(pack: linkLock!), tag: 1, selection: $action) {
EmptyView()
}
ScrollView(.vertical, showsIndicators: false) {
ForEach((allPacks), id: \.self) { pack in
//Checks if iOS version 14.0 is available to render the lazy grid view
if #available(iOS 14.0, *) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
if
//checks if the pack is in the purchased list - if so, renders it as an unlocked tile.
isPurchased(pack: pack, allPacks: purchasedPacks) {
UnlockedPackTile(tilePack: pack)
.onTapGesture {
print("Originally tapped \(pack.name)")
self.userInformation.defaultPack = pack
self.isPresented.toggle()
}
} else {
// if pack is not purchased, renders it as a locked pack tile
LockedPackTile(tilePackLocked: pack)
.onTapGesture {
self.linkLock = pack
self.action = 1
}
}
}
} else {
//does this as a simple VStack instead if iOS 14 is not available.
if isPurchased(pack: pack, allPacks: purchasedPacks) {
UnlockedPackTile(tilePack: pack)
.onTapGesture {
print("Originally tapped \(pack.name)")
self.userInformation.defaultPack = pack
self.isPresented.toggle()
}
} else {
LockedPackTile(tilePackLocked: pack)
.onTapGesture {
self.linkLock = pack
self.action = 1
}
}
}
}
}
Text("Button")
.navigationBarTitle("Question Packs")
}
}
}
}
You have to put ForEach inside LazyVGrid, so latter arranges dynamic views:
ScrollView(.vertical, showsIndicators: false) {
//Checks if iOS version 14.0 is available to render the lazy grid view
if #available(iOS 14.0, *) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
ForEach((allPacks), id: \.self) { pack in
Related
I'm using the beta version of Xcode 12.0 to play around with LazyVGrid, to render this grid within a scrollview if the phone has iOS 14, otherwise just to render the ScrollView as one column.
When I launch this on the app on my phone (not using iOS 14), opening this view causes my app to crash. But if I comment out the "if #available" section and just display what's in the "else" statement, it works fine.
Is there an issue with if #available in earlier versions of iOS or is my syntax just incorrect?
var body: some View {
NavigationView {
VStack {
//Empty View navigation link to choose the selected pack in User Defaults.
ScrollView(.vertical, showsIndicators: false) {
//Checks if iOS version 14.0 is available to render the lazy grid view
if #available(iOS 14.0, *) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 15) {
//checks if the pack is in the purchased list - if so, renders it as an unlocked tile.
ForEach((allPacks), id: \.self) { pack in
UnlockedPackTile(tilePack: pack)
.onTapGesture {
print("Originally tapped \(pack.name)")
self.userInformation.defaultPack = pack
self.isPresented.toggle()
}
}
}
} else {
//does this as a simple stack instead if iOS 14 is not available.
ForEach((allPacks), id: \.self) { pack in
UnlockedPackTile(tilePack: pack)
.onTapGesture {
print("Originally tapped \(pack.name)")
self.userInformation.defaultPack = pack
self.isPresented.toggle()
}
}
}
}
Try to wrap content of ScrollView into Group (or VStack) like
ScrollView(.vertical, showsIndicators: false) {
Group {
if #available(iOS 14.0, *) {
// ... new content here
} else {
// ... old content here
}
}
}
Consider the following project with two views. The first view presents the second one:
import SwiftUI
struct ContentView: View {
private let data = 0...1000
#State private var selection: Set<Int> = []
#State private var shouldShowSheet = false
var body: some View {
self.showSheet()
//self.showPush()
}
private func showSheet() -> some View {
Button(action: {
self.shouldShowSheet = true
}, label: {
Text("Selected: \(selection.count) items")
}).sheet(isPresented: self.$shouldShowSheet) {
EditFormView(selection: self.$selection)
}
}
private func showPush() -> some View {
NavigationView {
Button(action: {
self.shouldShowSheet = true
}, label: {
NavigationLink(destination: EditFormView(selection: self.$selection),
isActive: self.$shouldShowSheet,
label: {
Text("Selected: \(selection.count) items")
})
})
}
}
}
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
#State private var editMode: EditMode = .active
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
Text("\(value)")
}
}.environment(\.editMode, self.$editMode)
}
}
Steps to reproduce:
Create an app with the above two views
Run the app and present the sheet with the editable list
Select some items at random indexes, for example a handful at index 0-10 and another handful at index 90-100
Close the sheet by swiping down/tapping back button
Open the sheet again
Scroll to indexes 90-100 to view the selection in the reused cells
Expected:
The selected indexes as you had will be in “selected state”
Actual:
The selection you had before is not marked as selected in the UI, even though the binding passed to List contains those indexes.
This occurs both on the “sheet” presentation and the “navigation link” presentation.
If you select an item in the list, the “redraw” causes the original items that were originally not shown as selected to now be shown as selected.
Is there a way around this?
It looks like EditMode bug, worth submitting feedback to Apple. The possible solution is to use custom selection feature.
Here is a demo of approach (modified only part). Tested & worked with Xcode 11.4 / iOS 13.4
struct EditFormView: View {
private let data = 0...1000
#Binding var selection: Set<Int>
init(selection: Binding<Set<Int>>) {
self._selection = selection
}
var body: some View {
List(selection: self.$selection) {
ForEach(data, id: \.self) { value in
self.cell(for: value)
}
}
}
// also below can be separated into standalone view
private func cell(for value: Int) -> some View {
let selected = self.selection.contains(value)
return HStack {
Image(systemName: selected ? "checkmark.circle" : "circle")
.foregroundColor(selected ? Color.blue : nil)
.font(.system(size: 24))
.onTapGesture {
if selected {
self.selection.remove(value)
} else {
self.selection.insert(value)
}
}.padding(.trailing, 8)
Text("\(value)")
}
}
}
I'm pretty sure this is a bug in SwiftUI, but I wondered if anyone has encountered it and figured out a workaround. My normal use case is to have a search field appear, but I've simplified it to the point where a simple text string exhibits the bug.
Create a single-view app, copy this into ContentView, and run it. Tap the search icon twice, then scroll the view; you'll see the text scrolling UNDER the title.
import SwiftUI
struct ContentView: View {
private var items = (0 ... 50).map {String($0)}
#State private var condition = false
var searchButton: some View {
Button(action: {self.condition.toggle()}) {
Image(systemName: "magnifyingglass").imageScale(.large)
}
}
var body: some View {
NavigationView {
VStack {
if condition {
Text("Peekaboo")
}
List {
ForEach(items, id: \.self) {item in
HStack {
Text(item)
}
}
}
}
.navigationBarTitle("List of Items")
.navigationBarItems(leading: searchButton)
}
}
}
Maybe it is a bug, submit feedback to Apple, but currently this is how NavigationView behaves - it collapses navigation bar only if its top content is List/ScrollView/Form. So to solve the issue move your VStack either into a List or out of NavigationView
1)
var body: some View {
NavigationView {
List {
if condition {
Text("Peekaboo")
}
ForEach(items, id: \.self) {item in
2)
var body: some View {
VStack {
if condition {
Text("Peekaboo")
}
NavigationView {
List {
It seems that a View cannot cope with variable number of views.
A workaround this strange behavior is this:
import SwiftUI
struct ContentView: View {
private var items = (0 ... 50).map {String($0)}
#State private var condition = false
var searchButton: some View {
Button(action: {self.condition.toggle()}) {
Image(systemName: "magnifyingglass").imageScale(.large)
}
}
var body: some View {
NavigationView {
VStack {
if condition {
Text("Peekaboo")
} else {
Text("")
}
// or use this Text(condition ? "Peekaboo" : "")
List {
ForEach(items, id: \.self) {item in
HStack {
Text(item)
}
}
}
}
.navigationBarTitle("List of Items")
.navigationBarItems(leading: searchButton)
}
}
}
Let me know if it works, if not let us know what device/system you are using. Tested with Xcode 11.6 beta, Mac 10.15.5, target ios 13.5 and mac catalyst.
I have such VStack with list inside it
VStack(alignment: .leading, spacing: 16) {
Text("Contacts")
.font(.custom("AvenirNext-DemiBold", size: 20))
.foregroundColor(Color("DarkTitle"))
.padding(8).layoutPriority(1)
List(self.contacts) { contact in
ContactOption(contact: contact)
.padding(.horizontal, 4)
} //.frame(height: 240)
}
The problem with this code is that List tries to expand content as much as it can here taking up entire screen in spite of having just 4 contacts.
I can set this height to fixed value using frame(height: 240)
I consider wether there is possibility to enforce List to wrap its content like Text() view does.
i.e. if there is 4 rows in List wrap content to display just this 4 rows, if there is 8 rows expand to this 8 rows. Then I could set some max height ex. 400 above which List could not expand anymore and then it will be scrollable.
ok, i tried a bit and i am not sure whether you can use it or not, but check this out: (just tap on add and remofe to see how the list gets bigger and smaller)
struct ContactOption : View {
var contact: String
var body: some View {
Text(contact)
}
}
struct ListView : View {
var contacts: [String]
var body : some View {
// List(self.contacts, id: \.self) { contact in
// ContactOption(contact: contact)
// .padding(.horizontal, 4)
// }
List {
ForEach (contacts, id: \.self) { contact in
Text (contact)
}
}
}
}
struct ContentView: View {
#State var contacts = ["Chris", "Joe", "Carla", "another"]
var body: some View {
VStack() {
HStack {
Button("Add") {
self.contacts.append("dust")
}
Button("Remove") {
self.contacts = self.contacts.dropLast()
}
}
Text("Contacts")
.foregroundColor(Color.blue)
.padding(8).layoutPriority(1)
Form {
ListView(contacts: contacts)
Section(footer: Text("hi")) {
Text("hi")
}
}
Divider()
Text("end list")
.foregroundColor(Color.orange)
}
}
}
When creating a List view onAppear triggers for elements in that list the way you would expect: As soon as you scroll to that element the onAppear triggers. However, I'm trying to implement a horizontal list like this
ScrollView(.horizontal) {
HStack(spacing: mySpacing) {
ForEach(items) { item in
MyView(item: item)
.onAppear { \\do something }
}
}
}
Using this method the onAppear triggers for all items at once, that is to say: immediately, but I want the same behavior as for a List view. How would I go about doing this? Is there a manual way to trigger onAppear, or control when views load?
Why I want to achieve this: I have made a custom Image view that loads an image from an URL only when it appears (and substitutes a placeholder in the mean time), this works fine for a List view, but I'd like it to also work for my horizontal 'list'.
As per SwiftUI 2.0 (XCode 12 beta 1) this is finally natively solved:
In a LazyHStack (or any other grid or stack with the Lazy prefix) elements will only initialise (and therefore trigger onAppear) when they appear on screen.
Here is possible approach how to do this (tested/worked with Xcode 11.2 / iOS 13.2)
Demo: (just show dynamically first & last visible cell in scrollview)
A couple of important View extensions
extension View {
func rectReader(_ binding: Binding<CGRect>, in space: CoordinateSpace) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let rect = geometry.frame(in: space)
DispatchQueue.main.async {
binding.wrappedValue = rect
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
extension View {
func ifVisible(in rect: CGRect, in space: CoordinateSpace, execute: #escaping (CGRect) -> Void) -> some View {
self.background(GeometryReader { (geometry) -> AnyView in
let frame = geometry.frame(in: space)
if frame.intersects(rect) {
execute(frame)
}
return AnyView(Rectangle().fill(Color.clear))
})
}
}
And a demo view of how to use them with cell views being in scroll view
struct TestScrollViewOnVisible: View {
#State private var firstVisible: Int = 0
#State private var lastVisible: Int = 0
#State private var visibleRect: CGRect = .zero
var body: some View {
VStack {
HStack {
Text("<< \(firstVisible)")
Spacer()
Text("\(lastVisible) >> ")
}
Divider()
band()
}
}
func band() -> some View {
ScrollView(.horizontal) {
HStack(spacing: 10) {
ForEach(0..<50) { i in
self.cell(for: i)
.ifVisible(in: self.visibleRect, in: .named("my")) { rect in
print(">> become visible [\(i)]")
// do anything needed with visible rects, below is simple example
// (w/o taking into account spacing)
if rect.minX <= self.visibleRect.minX && self.firstVisible != i {
DispatchQueue.main.async {
self.firstVisible = i
}
} else
if rect.maxX >= self.visibleRect.maxX && self.lastVisible != i {
DispatchQueue.main.async {
self.lastVisible = i
}
}
}
}
}
}
.coordinateSpace(name: "my")
.rectReader(self.$visibleRect, in: .named("my"))
}
func cell(for idx: Int) -> some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color.yellow)
.frame(width: 80, height: 60)
.overlay(Text("\(idx)"))
}
}
I believe what you want to achieve can be done with LazyHStack.
ScrollView {
LazyVStack {
ForEach(1...100, id: \.self) { value in
Text("Row \(value)")
.onAppear {
// Write your code for onAppear here.
}
}
}
}