The Problem
In our brand new SwiftUI project (Multiplatform app for iPhone, iPad and Mac) we are using a ScrollView with both .horizontal and .vertical axes enabled. In the end it all looks like a spreadsheet.
Inside of ScrollView we are using LazyVStack with pinnedViews: [.sectionHeaders, .sectionFooters]. All Footer, Header and the content cells are wrapped into a LazyHStack each. The lazy stacks are needed due to performance reasones when rows and columns are growing.
And this exactly seems to be the problem. ScrollView produces a real layout mess when scrolling in both axes at the same time.
Tested Remedies
I tried several workarounds with no success.
We built our own lazy loading horizontal stack view which shows only the cells whose indexes are in the visible frame of the scrollview, and which sets dynamic calculated leading and trailing padding.
I tried the Introspect for SwiftUI packacke to set usesPredominantAxisScrolling for macOS and isDirectionalLockEnabled for iOS. None of this works as expected.
I also tried a workaround to manually lock one axis when the other one is scrolling. This works fine on iOS but it doesn't on macOS due to extreme poor performance (I think it's caused by the missing NSScrollViewDelegate and scroll events are being propagated via NotificationCenter).
How does it look?
To give you a better idea of what I mean, I have screenshots for both iOS and macOS.
Here you can watch short screen recording for both iOS and macOS.
Example Code
And this is the sample code used for the screenshots. So you can just create a new Multiplatform project and paste the code below in the ContentView.swift file. That's all.
import SwiftUI
let numberOfColums: Int = 150
let numberOfRows: Int = 350
struct ContentView: View {
var items: [[Item]] = {
var items: [[Item]] = [[]]
for rowIndex in 0..<numberOfRows {
var row: [Item] = []
for columnIndex in 0..<numberOfColums {
row.append(Item(column: columnIndex, row: rowIndex))
}
items.append(row)
}
return items
}()
var body: some View {
Group {
ScrollView([.horizontal, .vertical]) {
LazyVStack(alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders, .sectionFooters]) {
Section(header: Header()) {
ForEach(0..<items.count, id: \.self) { rowIndex in
let row = items[rowIndex]
LazyHStack(spacing: 1) {
ForEach(0..<row.count, id: \.self) { columnIndex in
Text("\(columnIndex):\(rowIndex)")
.frame(width: 100)
.padding()
.background(Color.gray.opacity(0.2))
}
}
}
}
}
}
.edgesIgnoringSafeArea([.top])
}
.padding(.top, 1)
}
}
struct Header: View {
var body: some View {
LazyHStack(spacing: 1) {
ForEach(0..<numberOfColums, id: \.self) { idx in
Text("Col \(idx)")
.frame(width: 100)
.padding()
.background(Color.gray)
}
Spacer()
}
}
}
struct Item: Hashable {
let id: String = UUID().uuidString
var column: Int
var row: Int
}
Can You Help?
Okay, long story short...
Does anybody know a real working solution for this issue?
Is it a known SwiftUI ScrollView bug?
If there were a way to lock one scrolling direction while the other one is being scrolled, then I could deal with that. But at the moment, unfortunately, it is not usable for us.
So any solution is highly appreciated! 😃
Is there any reason you don't want to use a Grid for this layout?
Here is the article I would suggest you read to solve this problem:
https://swiftui-lab.com/impossible-grids/
Related
I'm new to IOS and SwiftUI so please correct any incorrect assumptions I make.
I need to make a View in SwiftUI that has similar functionality to RecyclerView in android.
Specifically what I'm trying to make is a horizontal ScrollView where the displayed item (the one in the centre of the screen) is constructed by its index.
for example If I'm looking at index 0 then it will display a card with some date text in ie (1st Jan 2022)
then index 1 will display 2nd jan 2022, index 2 3rd jan 2022 ect...
So you can see it could theoretically go on forever if the user wants to scroll far enough.
Because of this I can't create a list of objects and loop over them in a HStack. So I want some kind of functionality where I only create 3 cards and then when i scroll to the right and the index 0 card goes out of sight, it is reused as the 'new' card at the right (end) of the ScrollView. The same behaviour as the RecyclerView.
The most important thing is I need to be able to access the index for a particular rendered card, without that i can't construct my data.
You can use LazyHGrid to achieve RecyclerView functionality
struct ContentView: View {
// MARK: - PROPERTIES
let gridItems = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View{
ScrollView(.horizontal, showsIndicators: false) {
LazyHGrid(rows: gridItems, alignment: .center, spacing: 10) {
ForEach(0...10, id: \.self){ number in
// You can create your own ReusableView and put it here
Text("\(number)")
.frame(width: 100)
.background(.blue)
}
}
}
.frame(height: 100)
}
}
// MARK: - PREVIEW
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Writing a Multiplatform app initially for macOS and iOS that uses a master detail view structure and within the detail view is a Form and many Sections, one of which contains two structs that represent a user's rating (of food) for enjoyment and consumption.
Here is the code for the enjoyment rating...
struct Rating: View {
#Environment(\.colorScheme) var colourScheme
#Binding var rating: Int
var body: some View {
HStack {
Text("Enjoyment")
.padding(.trailing, 12.0)
#if os(iOS)
Spacer()
#endif
ForEach(0..<5) { counter in
Image(systemName: rating > counter ? "star.fill" : "star")
.onTapGesture(count: 1) {
if rating == 1 {
rating = 0
}
else {
rating = counter + 1
}
}
// this line for image system name = "circle.fill" : "circle"
//.padding(.trailing, counter != 4 ? 2.5 : 0)
}
.shadow(color: colourScheme == .light ? .gray : .white, radius: colourScheme == .light ? 1.0 : 2.0, x: 0, y: 0)
}
.foregroundColor(Color.accentColor)
}
}
The consumption rating is very similar with minor changes.
So far these look good on iOS because the (iOS only) Spacer() pushes each Rating view to the right or trailing edge of the row and my .padding modifier hack for the circle image makes the spacing between each image "about right".
While I'm struggling to figure out how to align the "dots" for the macOS target, I'm also struggling to figure out how to align each image programmatically, so that if I changed the image the alignment would work.
See screenshots below (that illustrate how the five Images do not align).
iOS
macOS
I've read a few blogs on the .alignmentGuide modifier including Alignment guides in SwiftUI by Majid Jabrayilov.
It seems to be the way I should go but I'm stuck in my attempts on how to work this out.
I've added a comment about the use of Spacer() between your label and your rating control, but separately it's worth looking at the rating control itself.
Firstly, right now you're relying on the five elements sharing an HStack with the label, and using conditional padding logic within the loop to control the spacing between elements.
That part would be easier if you give your rating element its own HStack. That way, spacing between elements can be determined using the stack's spacing attribute without having to worry about whether or not you're on the last loop iteration. For example:
HStack(spacing: 2.5) {
ForEach(0..<5) {
Image(systemName: "circle")
// etc.
}
}
In terms of aligning the child elements of a rating view so that they align with a similar view below regardless of the symbol being used, you can constrain the frame width of each child element to be the same, regardless of what image they're displaying.
You can accomplish that by adding a .frame() modifier to each child element in the loop:
HStack {
ForEach(0..<5) {
Image(systemName: "xxx")
.frame(width: 40, alignment: .center)
}
}
You'd obviously need to pick a width that works for you - and you could mix this with a spacing attribute on the HStack as well.
I'm creating a small SwiftUI app with a TabView, but the code stopped working as soon as I used the modifier .tabViewStyle(PageTabViewStyle()). Why is that? The app is meant to cycle through a series of images and is going to be similar in design to the Photos app. It's based on a Kavsoft tutorial, but that tutorial used deprecated code. When I used the more modern equivalent, the app's preview stopped working, and I can no longer see the TabView.
struct Home: View {
/* Posts */
#State var posts: [Post] = []
/* Cuurent Image */
#State var currentImage = ""
var body: some View {
/* Gallery */
TabView(selection: $currentImage) {
ForEach(posts) { post in
/* GeometryReader to get screen sizes for image */
GeometryReader { geo in
let size = geo.size
Image(post.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size.width, height: size.height)
.cornerRadius(0)
}
.edgesIgnoringSafeArea(.all)
}
}
.tabViewStyle(PageTabViewStyle()) // Cannot work
.onAppear {
for index in 1...10 {
posts.append(Post(image: "\(index)"))
}
}
}
}
struct Post: Identifiable, Hashable {
var id = UUID().uuidString
var image: String
}
Adding an answer here for completeness, I suspected it would work with static content like Text(). This was crashing because ForEach is trying to load posts that don't exist yet, causing it to init from an empty array. For the tab view to work correctly, it needs at least one rendered view.
To fix this, posts needs to have at least one element in it to render.
I have built a scrollview that is populated after an API call is successful. The issue i am facing is after the first time content is populated all the listings are crammed onto the center of the screen. It is only after i close the view and reopen it that i see the proper listings properly fit on the screen. I have no idea, i've tried to readjust the frame with no luck.
The fact that when i navigate away and back then the view corrects itself is what's confusing me, had it been maintaining the same behavior it would be easily understandable
ScrollView(.vertical, showsIndicators: false){
if(searchResultsViewModel.listings.count == 0){
ActivityIndicator().foregroundColor(Color(hex:Global.colorAccent))
.frame(width: 30, height: 30).padding(EdgeInsets(top: 15,leading: 0,bottom: 0,trailing: 0))
}else {
ForEach(self.searchResultsViewModel.listings, id: \.self){ item in
HStack(spacing: 0) {
ForEach(item, id: \.self) { listing in
NavigationLink(destination: ListingDetailsView(listing: listing)) {
ListingView(listing: listing)
}.buttonStyle(PlainButtonStyle())
}
}
}
}
}.navigationBarTitle(title)
Below is an image of what im trying to explain
The solution is to force rebuild scrollview, because original does not know that content size changed.
So assuming self.searchResultsViewModel is observed view model
#State private var scrollViewID = UUID() // << initial
// ... other code
ScrollView(.vertical, showsIndicators: false){
// ... content here
}.id(scrollViewID) // << dynamic id
.navigationBarTitle(title)
.onReceive(self.searchResultsViewModel.$listings) { _ in
self.scrollViewID = UUID() // << force rebuild
}
For some reason, putting a GeometryReader as an intermediary, kills geometry of its nested views if it's a List "cell".
Example code:
struct SampleView: View {
var multilineText: some View {
Text(
"""
Some
Amazing
Multiline
Copy
"""
)
}
var body: some View {
List(1...5, id: \.self) { _ in
GeometryReader { _ in
self.multilineText
}
}
}
}
Without GeometryReader (Expected) / Actual with GeometryReader:
This example, obviously, is over-simplified, but there is a legitimate reason to measure geometry for a nested view I'm building.
This is on Xcode 11 beta 6. Should I go straight into reporting this as a bug, or it's something expected and workable around?
Add min row height for the list.
List(1...5, id: \.self) { _ in
GeometryReader { _ in
self.multilineText
}
}.environment(\.defaultMinListRowHeight, 100)