SwiftUI, How to stop GridItem from expanding - swift

I am following the course CS193p from Stanford, since im learning swift on my own and all the course material has been released (I am not enrolled).
Im currently working on the midterm projekt in which i have to develop the game "Set".
The cards should be displayed adaptively on the screen so that the cards get smaller or bigger when cards are added or removed to the board. The professor has released a struct which is supposed to handle this. (This works)
One of the requirements for the assignment is that all the cards should be equally sized. And this is where i am stuck. The cards with 3 shapes on them are overflowing into the other cards:
Screenshot
Here is the code:
struct AspectVGrid<Item, ItemView>: View where ItemView: View, Item: Identifiable {
var items: [Item]
var aspectRatio: CGFloat
var content: (Item) -> ItemView
init(items: [Item], aspectRatio: CGFloat, #ViewBuilder content: #escaping (Item) -> ItemView) {
self.items = items
self.aspectRatio = aspectRatio
self.content = content
}
var body: some View {
GeometryReader { geometry in
VStack {
let width: CGFloat = widthThatFits(itemCount: items.count, in: geometry.size, itemAspectRatio: aspectRatio)
LazyVGrid(columns: [adaptiveGridItem(width: width)], spacing: 0) {
ForEach(items) { item in
content(item).aspectRatio(aspectRatio, contentMode: .fit)
}
}
Spacer(minLength: 0)
}
}
}
private func adaptiveGridItem(width: CGFloat) -> GridItem {
var gridItem = GridItem(.adaptive(minimum: width))
gridItem.spacing = 0
return gridItem
}
private func widthThatFits(itemCount: Int, in size: CGSize, itemAspectRatio: CGFloat) -> CGFloat {
var columnCount = 1
var rowCount = itemCount
repeat {
let itemWidth = size.width / CGFloat(columnCount)
let itemHeight = itemWidth / itemAspectRatio
if CGFloat(rowCount) * itemHeight < size.height {
break
}
columnCount += 1
rowCount = (itemCount + (columnCount - 1)) / columnCount
} while columnCount < itemCount
if columnCount > itemCount {
columnCount = itemCount
}
return floor(size.width / CGFloat(columnCount))
}
}
I have tried using .scaleToFit, .frame, and changing diffrent sizes across the struct but nothing have worked so far.
Using .clipped is the closest i got since all elements now are the same size, except for the fact that the bottom of the blue card gets cut off.
I know i have to fix the height of the GridItem somehow but i cant figure it out.
Any help would be very much appreciated!
CardView() code:
struct CardView: View {
let card: Model.Card
var body: some View {
GeometryReader { geometry in
ZStack{
let shape = RoundedRectangle(cornerRadius: 10)
if !card.isMatched {
shape.fill().foregroundColor(.white)
shape.strokeBorder(lineWidth: 4).foregroundColor(card.isSelected ? .red : .blue)
//} else if card.isMatched {
// shape.opacity(0)
//} else {
// shape.fill()
}
VStack {
Spacer(minLength: 0)
ForEach(0..<card.numberOfShapes) { index in
cardShape().frame(height: geometry.size.height/5)
}
Spacer(minLength: 0)
}.padding()
.foregroundColor(setColor())
.aspectRatio(CGFloat(6.0/8.0), contentMode: .fit)
}
}
}

Related

Slide Carousel cards on Cards tap in SwiftUI

I have created a carousel cards in SwiftUI, it is working on the DragGesture
I want to achieve same experience on the tap of cards i.e. on .onTapGesture, which ever cards is being tapped it should be slide to centre on the screen like shown in the video attached
My current code -
import SwiftUI
struct Item: Identifiable {
var id: Int
var title: String
var color: Color
}
class Store: ObservableObject {
#Published var items: [Item]
let colors: [Color] = [.red, .orange, .blue, .teal, .mint, .green, .gray, .indigo, .black]
// dummy data
init() {
items = []
for i in 0...7 {
let new = Item(id: i, title: "Item \(i)", color: colors[i])
items.append(new)
}
}
}
struct ContentView: View {
#StateObject var store = Store()
#State private var snappedItem = 0.0
#State private var draggingItem = 0.0
#State var activeIndex: Int = 0
var body: some View {
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
}
}
.gesture(getDragGesture())
.onTapGesture {
//move card to centre
}
}
private func getDragGesture() -> some Gesture {
DragGesture()
.onChanged { value in
draggingItem = snappedItem + value.translation.width / 100
}
.onEnded { value in
withAnimation {
draggingItem = snappedItem + value.predictedEndTranslation.width / 100
draggingItem = round(draggingItem).remainder(dividingBy: Double(store.items.count))
snappedItem = draggingItem
//Get the active Item index
self.activeIndex = store.items.count + Int(draggingItem)
if self.activeIndex > store.items.count || Int(draggingItem) >= 0 {
self.activeIndex = Int(draggingItem)
}
}
}
}
func distance(_ item: Int) -> Double {
return (draggingItem - Double(item)).remainder(dividingBy: Double(store.items.count))
}
func myXOffset(_ item: Int) -> Double {
let angle = Double.pi * 2 / Double(store.items.count) * distance(item)
return sin(angle) * 200
}
}
You need to apply the .onTapGesture() modifier to the single item inside the ForEach, not around it.
Then, you just need to handle the different cases, comparing the tapped item with the one currently on the front, and change the value of draggingItem accordingly.
Here's the code inside the view's body:
ZStack {
ForEach(store.items) { item in
// article view
ZStack {
RoundedRectangle(cornerRadius: 18)
.fill(item.color)
Text(item.title)
.padding()
}
.frame(width: 200, height: 200)
.scaleEffect(1.0 - abs(distance(item.id)) * 0.2 )
.opacity(1.0 - abs(distance(item.id)) * 0.3 )
.offset(x: myXOffset(item.id), y: 0)
.zIndex(1.0 - abs(distance(item.id)) * 0.1)
// Here is the modifier - on the item, not on the ForEach
.onTapGesture {
// withAnimation is necessary
withAnimation {
draggingItem = Double(item.id)
}
}
}
}
.gesture(getDragGesture())

SwiftUI - Dynamic LazyHGrid row height

I'm creating vertical layout which has scrollable horizontal LazyHGrid in it. The problem is that views in LazyHGrid can have different heights (primarly because of dynamic text lines) but the grid always calculates height of itself based on first element in grid:
What I want is changing size of that light red rectangle based on visible items, so when there are smaller items visible it should look like this:
and when there are bigger items it should look like this:
This is code which results in state on the first image:
struct TestView: PreviewProvider {
static var previews: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem()],
alignment: .top,
spacing: 16
) {
Color.red
.frame(width: 64, height: 24)
ForEach(Array(0...10), id: \.self) { value in
Color.red
.frame(width: 64, height: CGFloat.random(in: 32...92))
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}
}
}
Something similar what I want can be achieved by this:
extension View {
func readSize(edgesIgnoringSafeArea: Edge.Set = [], onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
SwiftUI.Color.clear
.preference(key: ReadSizePreferenceKey.self, value: geometryProxy.size)
}.edgesIgnoringSafeArea(edgesIgnoringSafeArea)
)
.onPreferenceChange(ReadSizePreferenceKey.self) { size in
DispatchQueue.main.async { onChange(size) }
}
}
}
struct ReadSizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
struct Size: Equatable {
var height: CGFloat
var isValid: Bool
}
struct TestView: View {
#State private var sizes = [Int: Size]()
#State private var height: CGFloat = 32
static let values: [(Int, CGFloat)] =
(0...3).map { ($0, CGFloat(32)) }
+ (4...10).map { ($0, CGFloat(92)) }
var body: some View {
ScrollView {
VStack {
Color.blue
.frame(height: 100)
ScrollView(.horizontal) {
LazyHGrid(
rows: [GridItem(.fixed(height))],
alignment: .top,
spacing: 16
) {
ForEach(Array(Self.values), id: \.0) { value in
Color.red
.frame(width: 300, height: value.1)
.readSize { sizes[value.0]?.height = $0.height }
.onAppear {
if sizes[value.0] == nil {
sizes[value.0] = Size(height: .zero, isValid: true)
} else {
sizes[value.0]?.isValid = true
}
}
.onDisappear { sizes[value.0]?.isValid = false }
}
}.padding()
}.background(Color.red.opacity(0.3))
Color.green
.frame(height: 100)
}
}.onChange(of: sizes) { sizes in
height = sizes.filter { $0.1.isValid }.map { $0.1.height }.max() ?? 32
}
}
}
... but as you see its kind of laggy and a little bit complicated, isn't there better solution? Thank you everyone!
The height of a row in a LazyHGrid is driven by the height of the tallest cell. According to the example you provided, the data source will only show a smaller height if it has only a small size at the beginning.
Unless the first rendering will know that there are different heights, use the larger value as the height.
Is your expected UI behaviour that the height will automatically switch? Or use the highest height from the start.

SwiftUI | GeometryReader: Smooth resizable Header when scrolling through List

I am new to SwiftUI and I want to recreate the Contact-Card View from the Contacts App.
I am struggling to resize the Image on the top smoothly when scrolling in the List below.
I have tried using GeometryReader, but ran into issues there.
When scrolling up for example, the picture size just jumps abruptly to the minimumPictureSize I have specified. The opposite happens when scrolling up: It stops resizing abruptly when I stop scrolling.
Wanted behaviour: https://gifyu.com/image/Ai04
Current behaviour: https://gifyu.com/image/AjIc
struct SwiftUIView: View {
#State var startOffset: CGFloat = 0
#State var offset: CGFloat = 0
var minPictureSize: CGFloat = 100
var maxPictureSize: CGFloat = 200
var body: some View {
VStack {
Image("person")
.resizable()
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
.mask(Circle())
Text("startOffset: \(startOffset)")
Text("offset: \(offset)")
List {
Section {
Text("Top Section")
}.overlay(
GeometryReader(){ geometry -> Color in
let rect = geometry.frame(in: .global)
if startOffset == 0 {
DispatchQueue.main.async {
startOffset = rect.minY
}
}
DispatchQueue.main.async {
offset = rect.minY - startOffset
}
return Color.clear
}
)
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}.listStyle(InsetGroupedListStyle())
}.navigationBarHidden(true)
}
}
Not a perfect solution, but you could separate the header and List into 2 layers in a ZStack:
struct SwiftUIView: View {
#State var startOffset: CGFloat!
#State var offset: CGFloat = 0
let minPictureSize: CGFloat = 100
let maxPictureSize: CGFloat = 200
var body: some View {
ZStack(alignment: .top) {
if startOffset != nil {
List {
Section {
Text("Top Section")
} header: {
// Leave extra space for `List` so it won't clip its content
Color.clear.frame(height: 100)
}
.overlay {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
offset = frame.minY - startOffset
}
return Color.clear
}
}
ForEach((0..<10)) { row in
Section {
Text("\(row)")
}
}
}
.listStyle(InsetGroupedListStyle())
.padding(.top, startOffset-100) // Make up extra space
}
VStack {
Circle().fill(.secondary)
.frame(width: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)),
height: max(minPictureSize, min(maxPictureSize, minPictureSize + offset)))
Text("startOffset: \(startOffset ?? -1)")
Text("offset: \(offset)")
}
.frame(maxWidth: .infinity)
.padding(.bottom, 20)
.background(Color(uiColor: UIColor.systemBackground))
.overlay {
if startOffset == nil {
GeometryReader { geometry -> Color in
DispatchQueue.main.async {
let frame = geometry.frame(in: .global)
startOffset = frame.maxY + // Original small one
maxPictureSize - minPictureSize -
frame.minY // Top safe area height
}
return Color.clear
}
}
}
}
.navigationBarHidden(true)
}
}
Notice that Color.clear.frame(height: 100) and .padding(.top, startOffset-100) are intended to leave extra space for List to avoid being clipped, which will cause the scroll bar get clipped. Alternatively, UIScrollView.appearance().clipsToBounds = true will work. However, it'll make element which moves outside the bounds of List disappear. Don't know if it's a bug.

A view extension that runs conditional code based on its GeometryReader results

I’ve created a View extension to read its offset (inspired by https://fivestars.blog/swiftui/swiftui-share-layout-information.html):
func readOffset(in coordinateSpace: String? = nil, onChange: #escaping (CGFloat) -> Void) -> some View {
background(
GeometryReader {
Color.clear.preference(key: ViewOffsetKey.self,
value: -$0.frame(in: coordinateSpace == nil ? .global : .named(coordinateSpace)).origin.y)
})
.onPreferenceChange(ViewOffsetKey.self, perform: onChange)
}
I’m also using Federico’s readSize function:
func readSize(onChange: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geo in
Color.clear
.preference(key: SizePreferenceKey.self, value: geo.size)
})
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
The two work together to help me determine whether a child view within a scrollview is on/off-screen:
struct TestInfinityList: View {
#State var visibleItems: Set<Int> = []
#State var items: [Int] = Array(0...20)
#State var size: CGSize = .zero
var body: some View {
ScrollView(.vertical) {
ForEach(items, id: \.self) { item in
GeometryReader { geo in
VStack {
Text("Item \(item)")
}.id(item)
.readOffset(in: "scroll") { newOffset in
if !isOffscreen(when: newOffset, in: size.height) {
visibleItems.insert(item)
}
else {
visibleItems.remove(item)
}
}
}.frame(height: 300)
}
}.coordinateSpace(name: "scroll")
}
.readSize { newSize in
self.size = newSize
}
}
This is the isOffscreen function that checks for visibility:
func isOffscreen(when offset: CGFloat, in height: CGFloat) -> Bool {
if offset <= 0 && offset + height >= 0 {
return false
}
return true
}
Everything works fine. However, I’d like to optimise the code further into a single extension that checks for visibility based on the offset and size.height inputted, and also receives parameters for what to do if visible and when not i.e. move readOffset’s closure to be logic that co-exists with the extension code.
I’ve no idea whether this is feasible but thought it’s worth an ask.
You just need to create a View or ViewModifier that demands some Bindings. Note, the code below is just an example of some of the patterns you can use (e.g., an optional binding, escaping content closure), but in the form of a Stack style wrap rather than a ViewModifier (which based on the blog you know how to setup).
struct ScrollableVStack<Content: View>: View {
let content: Content
#Binding var useScrollView: Bool
#Binding var scroller: ScrollViewProxy?
#State private var staticGeo = ViewGeometry()
#State private var scrollContainerGeo = ViewGeometry()
let topFade: CGFloat
let bottomFade: CGFloat
init(_ useScrollView: Binding<Bool>,
topFade: CGFloat = 0.09,
bottomFade: CGFloat = 0.09,
_ scroller: Binding<ScrollViewProxy?> = .constant(nil),
#ViewBuilder _ content: #escaping () -> Content ) {
_useScrollView = useScrollView
_scroller = scroller
self.content = content()
self.topFade = topFade
self.bottomFade = bottomFade
}
var body: some View {
if useScrollView { scrollView }
else { VStack { staticContent } }
}
var scrollView: some View {
ScrollViewReader { scroller in
ScrollView(.vertical, showsIndicators: false) {
staticContent
.onAppear { self.scroller = scroller }
}
.geometry($scrollContainerGeo)
.fadeInOut(topFade: staticGeo.size.height * topFade,
bottomFade: staticGeo.size.height * bottomFade)
}
.onChange(of: staticGeo.size.height) { newStaticHeight in
useScrollView = newStaticHeight > scrollContainerGeo.size.height * 0.85
}
}
var staticContent: some View {
content
.geometry($staticGeo)
.padding(.top, staticGeo.size.height * topFade * 1.25)
.padding(.bottom, staticGeo.size.height * bottomFade)
}
}

Gesture on a view inside a scrollView blocks the scrolling

I have been trying to achieve paging in swiftUI, which I pretty much achieved using two approaches. But the problem is that the paging Row is inside a vertical scroll. So, the gestures used for paging blocks the vertical scroll.
One of the approaches used for paging is below:
struct SwiftUIPagerView<Content: View & Identifiable>: View {
#Binding var index: Int
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
// 1
var pages: [Content]
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
page
.frame(width: geometry.size.width, height: nil)
}
}
}
// 2
.content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
// 3
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.simultaneousGesture(DragGesture().onChanged({ value in
// 4
self.isGestureActive = true
// 5
self.offset = value.translation.width + -geometry.size.width * CGFloat(self.index)
}).onEnded({ value in
if -value.predictedEndTranslation.width > geometry.size.width / 2, self.index < self.pages.endIndex - 1 {
self.index += 1
}
if value.predictedEndTranslation.width > geometry.size.width / 2, self.index > 0 {
self.index -= 1
}
// 6
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
// 7
DispatchQueue.main.async { self.isGestureActive = false }
}))
}
}
}
Further, I use it like:
ScrollView(.vertical) {
SwiftUIPagerView(index: self.$activePageIndex, pages: (0..<4).map {
index in HomeBottleCard(bottle: DataSource.getBottles()[0])
})
SwiftUIPagerView(index: self.$activePageIndex, pages: (0..<4).map {
index in HomeBottleCard(bottle: DataSource.getBottles()[0])
})
}
Now the problem arises here. The Vertical scroll does not work. I have tried help from similar questions like Adding a drag gesture in SwiftUI to a View inside a ScrollView blocks the scrolling
But nothing helped. I would appreciate if anyone could come up with an idea to resolve this issue.