Change array sequence in SwiftUI DragGesture - swiftui

Im currently trying to build a deck of cards to represent the users cards.
This is my first prototype, please ignore the styling issues. I want the user to be able to change the sequence of the cards, which i tried to implement with a drag gesture.
My idea was that when the drag gesture's translation is bigger than the cards offset (or smaller than the cards negative offset) I just swap the two elements inside my array to swap the cards in the view (and viewModel because of the binding). Unfortunately that's not working and I cannot figure out why. This is my View:
struct CardHand: View {
#Binding var cards: [Card]
#State var activeCardIndex: Int?
#State var activeCardOffset: CGSize = CGSize.zero
var body: some View {
ZStack {
ForEach(cards.indices) { index in
GameCard(card: cards[index])
.offset(x: CGFloat(-30 * index), y: self.activeCardIndex == index ? -20 : 0)
.offset(activeCardIndex == index ? activeCardOffset : CGSize.zero)
.rotationEffect(Angle.degrees(Double(-2 * Double(index - cards.count))))
.zIndex(activeCardIndex == index ? 100 : Double(index))
.gesture(getDragGesture(for: index))
}
// Move the stack back to the center of the screen
.offset(x: CGFloat(12 * cards.count), y: 0)
}
}
private func getDragGesture(for index: Int) -> some Gesture {
DragGesture()
.onChanged { gesture in
self.activeCardIndex = index
self.activeCardOffset = gesture.translation
}
.onEnded { gesture in
if gesture.translation.width > 30, index < cards.count {
cards.swapAt(index, index + 1)
}
self.activeCardIndex = nil
// DEBUG: Try removing the card after drag gesture ended. Not working either.
cards.remove(at: index)
}
}
}
The GameCard:
struct GameCard: View {
let card: Card
var symbol: (String, Color) {
switch card.suit.name {
case "diamonds":
return ("♦", .red)
case "hearts":
return ("♥", .red)
case "spades":
return ("♠", .black)
case "clubs":
return ("♣", .black)
default:
return ("none", .black)
}
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white)
.addBorder(Color.black, width: 3, cornerRadius: 25)
VStack {
Text(self.card.rank.type.description())
Text(self.symbol.0)
.font(.system(size: 100))
.foregroundColor(self.symbol.1)
}
}
.frame(minWidth: 20, idealWidth: 80, maxWidth: 80, minHeight: 100, idealHeight: 100, maxHeight: 100, alignment: .center)
}
}
The Model behind it:
public struct Card: Identifiable, Hashable {
public var id: UUID = UUID()
public var suit: Suit
public var rank: Rank
}
public enum Type: Identifiable, Hashable {
case face(String)
case numeric(rankNumber: Int)
public var id: Type { self }
public func description() -> String{
switch self {
case .face(let description):
return description
case .numeric(let rankNumber):
return String(rankNumber)
}
}
}
public struct Rank: RankProtocol, Identifiable, Hashable {
public var id: UUID = UUID()
public var type: Type
public var value: Int
public var ranking: Int
}
public struct Suit: SuitProtocol, Identifiable, Hashable {
public var id: UUID = UUID()
public var name: String
public var value: Int
public init(name: String, value: Int) {
self.name = name
self.value = value
}
}
public protocol RankProtocol {
var type: Type { get }
var value: Int { get }
}
public protocol SuitProtocol {
var name: String { get }
}
Can anyone tell me why this is not working as I expected? Did I miss anything basic?
Thank you!

Lots of little bugs going on here. Here's a (rough) solution, which I'll detail below:
struct CardHand: View {
#Binding var cards: [Card]
#State var activeCardIndex: Int?
#State var activeCardOffset: CGSize = CGSize.zero
func offsetForCardIndex(index: Int) -> CGSize {
var initialOffset = CGSize(width: CGFloat(-30 * index), height: 0)
guard index == activeCardIndex else {
return initialOffset
}
initialOffset.width += activeCardOffset.width
initialOffset.height += activeCardOffset.height
return initialOffset
}
var body: some View {
ZStack {
ForEach(cards.indices) { index in
GameCard(card: cards[index])
.offset(offsetForCardIndex(index: index))
.rotationEffect(Angle.degrees(Double(-2 * Double(index - cards.count))))
.zIndex(activeCardIndex == index ? 100 : Double(index))
.gesture(getDragGesture(for: index))
}
// Move the stack back to the center of the screen
.offset(x: CGFloat(12 * cards.count), y: 0)
}
}
private func getDragGesture(for index: Int) -> some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { gesture in
self.activeCardIndex = index
self.activeCardOffset = gesture.translation
}
.onEnded { gesture in
if gesture.translation.width > 30 {
if index - 1 > 0 {
cards.swapAt(index, index - 1)
}
}
if gesture.translation.width < -30 {
cards.swapAt(index, index + 1)
}
self.activeCardIndex = nil
}
}
}
Trying to do two offset() calls in a row is problematic. I combined the two into offsetForCardIndex
Because you're doing a ZStack, remember that the first item in the cards array will be at the bottom of the stack and towards the right of the screen. That affected the logic later
You'll want to make sure you check the bounds of the array in swapAt so that you don't end up trying to swap beyond the indexes (which was what was happening before)
My "solution" is still pretty rough -- there should be more checking in place to make sure that the array can't go out of bounds. Also, in your original and in mine, it would probably make more sense from a UX perspective to be able to swap more than 1 position. But, that's a bigger issue outside the scope of this question.

Related

SwiftUI Preference Key to make uniformly sized boxes

UPDATE: I've made this minimally reproducible.
I wish to make a grid for the alphabet, with each box the same size, looking like this:
I have a PreferenceKey, and a View extension, like this:
struct WidthPreference: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
extension View {
func sizePreference(letterIdx: Int) -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
My primary view is an HStack nestled in a VStack, with each letter as a separate view. Here is the ContentView, and its Alphabet Grid:
struct ContentView: View {
#StateObject var theModel = MyModel()
var body: some View {
AlphabetGrid()
.textCase(.uppercase)
.font(.body)
.onAppear() {
theModel.initializeLetters()
}
.environmentObject(theModel)
}
}
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue)
}
}
}
}
}
}
}
And then the Letter view, for each letter:
struct LetterView: View {
#EnvironmentObject var theModel: MyModel
var theIdx: Int
var borderColour: Color
var spacing: CGFloat = 8
#State private var cellWidth: CGFloat? = nil
func letterFor(letterIdx: Int) -> some View {
Text(String(theModel.allLetters?[letterIdx].letterStoreChar ?? "*"))
.sizePreference(letterIdx: letterIdx)
.frame(width: cellWidth, height: cellWidth, alignment: .center)
.padding(spacing)
}
var body: some View {
self.letterFor(letterIdx: theIdx)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(borderColour, lineWidth: 1)
)
.onPreferenceChange(WidthPreference.self) { self.cellWidth = $0 }
}
}
Finally, for completeness, the Model to store the letters:
class MyModel: ObservableObject {
#Published var allLetters: [LetterData]?
struct LetterData: Hashable {
let letterStorePosition: Int
let letterStoreChar: Character
let keyboardRow: Int
let keyboardCol: Int
}
let keyboardWide = 9 // characters per row
// put all the alphabet characters into an array of LetterData elements
func initializeLetters() {
var tempLetters: [LetterData] = []
let allChars = Array("abcdefghijklmnopqrstuvwxyz")
for (index, element) in allChars.enumerated() {
let row = index / keyboardWide
let col = index % keyboardWide
tempLetters.append(LetterData(letterStorePosition: index, letterStoreChar: element,
keyboardRow: row, keyboardCol: col))
}
allLetters = tempLetters
}
}
Unfortunately, this makes a pretty, yet incorrect grid like this:
Any ideas on where I'm going wrong?
I did some digging, your PreferenceKey is being set with .background which just takes the size of the current View and you are using that value to turn into a square.
There is no match for the average just taking the current width and using it for the height.
extension View {
func sizePreference(letterIdx: Int) -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
.frame(width: cellWidth, height: cellWidth, alignment: .center)
The width is based on the letter I being the most narrow and W being the widest.
Now, how to "fix" your code. You can move the onPreferenceChange up one View and use the min between the current cellWidth and the $0 instead of just replacing.
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
#State private var cellWidth: CGFloat = .infinity
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue, cellWidth: $cellWidth)
}
}
}
}
} .onPreferenceChange(WidthPreference.self) { self.cellWidth = min(cellWidth, $0 ?? .infinity) }
}
}
Now with that fix you get a better looking keyboard but the M and W are cut off, to use the max you need a little more tweaking, ou can look at the code below.
import SwiftUI
class MyModel: ObservableObject {
#Published var allLetters: [LetterData]?
struct LetterData: Hashable {
let letterStorePosition: Int
let letterStoreChar: Character
let keyboardRow: Int
let keyboardCol: Int
}
let keyboardWide = 9 // characters per row
// put all the alphabet characters into an array of LetterData elements
func initializeLetters() {
var tempLetters: [LetterData] = []
let allChars = Array("abcdefghijklmnopqrstuvwxyz")
for (index, element) in allChars.enumerated() {
let row = index / keyboardWide
let col = index % keyboardWide
tempLetters.append(LetterData(letterStorePosition: index, letterStoreChar: element,
keyboardRow: row, keyboardCol: col))
}
allLetters = tempLetters
}
}
struct AlphabetParentView: View {
#StateObject var theModel = MyModel()
var body: some View {
AlphabetGrid()
.textCase(.uppercase)
.font(.body)
.onAppear() {
theModel.initializeLetters()
}
.environmentObject(theModel)
}
}
struct LetterView: View {
#EnvironmentObject var theModel: MyModel
var theIdx: Int
var borderColour: Color
var spacing: CGFloat = 8
#Binding var cellWidth: CGFloat?
func letterFor(letterIdx: Int) -> some View {
Text(String(theModel.allLetters?[letterIdx].letterStoreChar ?? "*"))
.padding(spacing)
}
var body: some View {
RoundedRectangle(cornerRadius: 8)
.stroke(borderColour, lineWidth: 1)
.overlay {
self.letterFor(letterIdx: theIdx)
}
.frame(width: cellWidth, height: cellWidth, alignment: .center)
}
}
struct AlphabetGrid: View {
#EnvironmentObject var theModel: MyModel
#State private var cellWidth: CGFloat? = nil
var spacing: CGFloat = 8
var body: some View {
let theKeyboard = [ theModel.allLetters?.filter { $0.keyboardRow == 0 },
theModel.allLetters?.filter { $0.keyboardRow == 1 },
theModel.allLetters?.filter { $0.keyboardRow == 2 }
]
VStack {
ForEach(theKeyboard, id: \.self) { keyboardRow in
HStack(alignment: .top) {
if let keyboardRow = keyboardRow {
ForEach(keyboardRow, id: \.self) { keyboardLetter in
let idx = keyboardLetter.letterStorePosition
LetterView(theIdx: idx, borderColour: .blue, cellWidth: $cellWidth)
.sizePreference()
}
}
}
}
} .onPreferenceChange(WidthPreference.self) {
if let w = cellWidth{
self.cellWidth = min(w, $0 ?? .infinity)
}else{
self.cellWidth = $0
}
}
}
}
struct AlphabetParentView_Previews: PreviewProvider {
static var previews: some View {
AlphabetParentView()
}
}
struct WidthPreference: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
extension View {
func sizePreference() -> some View {
background(GeometryReader { proxy in
Color.clear
.preference(key: WidthPreference.self, value: proxy.size.width)
})
}
}
There are simpler way of handling this like Ashley's example or SwiftUI.Layout and layout but this should help you understand why your squares were uneven.
Here's a fairly simple implementation, using a GeometryReader to allow us to calculate the width (and therefore the height), of each letter
struct ContentView: View {
let letters = ["ABCDEFGHI","JKLMNOPQR","STUVWXYZ"]
let spacing: CGFloat = 8
var body: some View {
GeometryReader { proxy in
VStack(spacing: spacing) {
ForEach(letters, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(Array(row), id: \.self) { letter in
Text(String(letter))
.frame(width: letterWidth(for: proxy.size.width), height: letterWidth(for: proxy.size.width))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(.cyan, lineWidth: 1)
)
}
}
}
}
}
.padding()
}
func letterWidth(for width: CGFloat) -> CGFloat {
let count = CGFloat(letters.map(\.count).max()!)
return (width - (spacing * (count - 1))) / count
}
}

SwiftUICharts are not redrawn when given new data

I am adding the possibility to swipe in order to update a barchart. What I want to show is statistics for different station. To view different station I want the user to be able to swipe between the stations. I can see that the swiping works and each time I swipe I get the correct data from my controller. The problem is that my view is not redrawn properly.
I found this guide, but cannot make it work.
Say I swipe right from station 0 with data [100, 100, 100] to station 2, the retrieved data from my controller is [0.0, 100.0, 0.0]. The view I have still is for [100, 100, 100]`.
The station number is correctly updated, so I suspect it needs some state somehow.
Here is the code:
import SwiftUI
import SwiftUICharts
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName()).tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}
struct BarCharts: View {
var data: [Double]
var title: String
init(data: [Double], disciplineName: String) {
self.data = data
title = disciplineName
print(data)
}
var body: some View {
VStack {
BarChartView(data: ChartData(points: self.data), title: self.title, style: Styles.barChartStyleOrangeLight, form: CGSize(width: 300, height: 400))
}
}
}
class ViewModel: ObservableObject {
#Published var station = 1
let controller = DetailedViewController()
var isPreview = false
func getData(kLatestRounds: Int, station: Int) -> [Double] {
if isPreview {
return [100.0, 100.0, 100.0]
} else {
let data = controller.getResults(kLatestRounds: kLatestRounds, station: station, fileName: userDataFile)
return data
}
}
func getName() -> String {
controller.getDiscipline().name
}
func getNumberOfStations() -> Int {
controller.getDiscipline().getNumberOfStations()
}
func getStation() -> Int {
station
}
func incrementStation() {
station = (station + 1) % getNumberOfStations()
}
func decrementStation() {
station -= 1
if station < 0 {
station = getNumberOfStations() - 1
}
}
}
The data is printed inside the constructor each time I swipe. Shouldn't that mean it should be updated?
I don’t use SwiftUICharts so I can’t test it, but the least you can try is manually set the id to the view
struct DetailedResultsView: View {
#ObservedObject var viewModel: ViewModel = .init()
#State private var tabIndex: Int = 0
#State private var startPos: CGPoint = .zero
#State private var isSwiping = true
var body: some View {
VStack {
Text("Station \(viewModel.getStation() + 1)")
TabView(selection: $tabIndex) {
BarCharts(data: viewModel.getData(kLatestRounds: 10, station: viewModel.getStation()), disciplineName: viewModel.getName())
.id(viewmodel.station) // here. If it doesn’t work, you can set it to the whole TabView
.tabItem { Group {
Image(systemName: "chart.bar")
Text("Last 10 Sessions")
}}.tag(0)
}
}.gesture(DragGesture()
.onChanged { gesture in
if self.isSwiping {
self.startPos = gesture.location
self.isSwiping.toggle()
}
}
.onEnded { gesture in
if gesture.location.x - startPos.x > 10 {
viewModel.decrementStation()
}
if gesture.location.x - startPos.x < -10 {
viewModel.incrementStation()
}
}
)
}
}

SwiftUI - How to make a selectable scrollable list?

I'm trying to make something like this:
before scrolling
after scrolling
Essentially:
The items are arranged in a scrollable list
The item located at the center is the one selected
The selected item's properties are accessible (by updating #State variables)
Ideally the scroll gesture is "sticky." For example, whichever item closest to the center after scrolling readjusts its position to the center, so that the overall arrangement is the same.
I've tried using a ScrollView but I have no idea of how to implement 2 and 4. I guess the idea is quite similar to a Picker?
I've been stuck on this for a while. Any suggestion would be greatly appreciated. Thanks in advance!
You could detect the location of items to select the centred one.
// Data model
struct Item: Identifiable {
let id = UUID()
var value: Int
// Other properties...
var loc: CGRect = .zero
}
struct ContentView: View {
#State private var ruler: CGFloat!
#State private var items = (0..<10).map { Item(value: $0) }
#State private var centredItem: Item!
var body: some View {
HStack {
if let item = centredItem {
Text("\(item.value)")
}
HStack(spacing: -6) {
Rectangle()
.frame(height: 1)
.measureLoc { loc in
ruler = (loc.minY + loc.maxY) / 2
}
Image(systemName: "powerplug.fill")
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
ForEach($items) { $item in
Text("\(item.value)")
.padding()
.frame(width: 80, height: 80)
.background(centredItem != nil &&
centredItem.id == item.id ? .yellow : .white)
.border(.secondary)
.measureLoc { loc in
item.loc = loc
if let ruler = ruler {
if item.loc.maxY >= ruler && item.loc.minY <= ruler {
withAnimation(.easeOut) {
centredItem = item
}
}
// Move outsides
if ruler <= items.first!.loc.minY ||
ruler >= items.last!.loc.maxY {
withAnimation(.easeOut) {
centredItem = nil
}
}
}
}
}
}
// Extra space above and below
.padding(.vertical, ruler)
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Detect location:
struct LocKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}
extension View {
func measureLoc(_ perform: #escaping (CGRect) ->()) -> some View {
overlay(GeometryReader { geo in
Color.clear
.preference(key: LocKey.self, value: geo.frame(in: .global))
}.onPreferenceChange(LocKey.self, perform: perform))
}
}

SwiftUI - Constructing a LazyVGrid with expandable views

I'm trying to construct a two-column grid of quadratic views from an array of colors where one view expands to the size of four small views when clicked.
Javier from swiftui-lab.com gave me kind of a breakthrough with the idea of adding Color.clear as a "fake" view inside the ForEach to trick the VGrid into making space for the expanded view. This works fine for the boxes on the left of the grid. The boxes on the right, however, give me a no ends of trouble because they expand to the right and don't cause the VGrid to reallign properly:
I've tried several things like swapping the colors in the array, rotating the whole grid when one of the views on the right is clicked, adding varying numbers of Color.clear views - nothing has done the trick so far.
Here's the current code:
struct ContentView: View {
#State private var selectedColor : UIColor? = nil
let colors : [UIColor] = [.red, .yellow, .green, .orange, .blue, .magenta, .purple, .black]
private let padding : CGFloat = 10
var body: some View {
GeometryReader { proxy in
ScrollView {
LazyVGrid(columns: [
GridItem(.fixed(proxy.size.width / 2 - 5), spacing: padding, alignment: .leading),
GridItem(.fixed(proxy.size.width / 2 - 5))
], spacing: padding) {
ForEach(0..<colors.count, id: \.self) { id in
if selectedColor == colors[id] && id % 2 != 0 {
Color.clear
}
RectangleView(proxy: proxy, colors: colors, id: id, selectedColor: selectedColor, padding: padding)
.onTapGesture {
withAnimation{
if selectedColor == colors[id] {
selectedColor = nil
} else {
selectedColor = colors[id]
}
}
}
if selectedColor == colors[id] {
Color.clear
Color.clear
Color.clear
}
}
}
}
}.padding(.all, 10)
}
}
RectangleView:
struct RectangleView: View {
var proxy: GeometryProxy
var colors : [UIColor]
var id: Int
var selectedColor : UIColor?
var padding : CGFloat
var body: some View {
Color(colors[id])
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: resolveOffset(for: id))
}
// Used to offset the boxes after the expanded one to compensate for missing padding
func resolveOffset(for id: Int) -> CGFloat {
guard let selectedColor = selectedColor, let selectedIndex = colors.firstIndex(of: selectedColor) else { return 0 }
if id > selectedIndex {
return -(padding * 2)
}
return 0
}
func calculateFrame(for id: Int) -> CGFloat {
selectedColor == colors[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
I would be really grateful if you could point me in the direction of what I'm doing wrong.
P.S. If you run the code, you'll notice that the last black box is also not behaving as expected. That's another issue that I've not been able to solve thus far.
After giving up on the LazyVGrid to do the job, I kind of "hacked" two simple VStacks to be contained in a ParallelStackView. It lacks the beautiful crossover animation a LazyVGrid has and can only be implemented for two columns, but gets the job done - kind of. This is obviously a far cry from an elegant solution but I needed a workaround, so for anyone working on the same issue, here's the code (implemented as generic over the type it contains):
struct ParallelStackView<T: Equatable, Content: View>: View {
let padding : CGFloat
let elements : [T]
#Binding var currentlySelectedItem : T?
let content : (T) -> Content
#State private var selectedElement : T? = nil
#State private var selectedSecondElement : T? = nil
var body: some View {
let (transformedFirstArray, transformedSecondArray) = transformArray(array: elements)
func resolveClearViewHeightForFirstArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedSecondArray[id+1] == selectedSecondElement || (transformedSecondArray[1] == selectedSecondElement && id == 0) ? proxy.size.width + padding : 0
}
func resolveClearViewHeightForSecondArray(id: Int, for proxy: GeometryProxy) -> CGFloat {
transformedFirstArray[id+1] == selectedElement || (transformedFirstArray[1] == selectedElement && id == 0) ? proxy.size.width + padding : 0
}
return GeometryReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedFirstArray.count, id: \.self) { id in
if transformedFirstArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForFirstArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedFirstArray, id: id, selectedElement: selectedElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedElement == transformedFirstArray[id] {
selectedElement = nil
currentlySelectedItem = nil
} else {
selectedSecondElement = nil
selectedElement = transformedFirstArray[id]
currentlySelectedItem = selectedElement
}
}
}
}
}
}
VStack(alignment: .leading, spacing: padding / 2) {
ForEach(0..<transformedSecondArray.count, id: \.self) { id in
if transformedSecondArray[id] == nil {
Color.clear.frame(
width: proxy.size.width / 2 - padding / 2,
height: resolveClearViewHeightForSecondArray(id: id, for: proxy))
} else {
RectangleView(proxy: proxy, elements: transformedSecondArray, id: id, selectedElement: selectedSecondElement, padding: padding, content: content)
.onTapGesture {
withAnimation(.spring()){
if selectedSecondElement == transformedSecondArray[id] {
selectedSecondElement = nil
currentlySelectedItem = nil
} else {
selectedElement = nil
selectedSecondElement = transformedSecondArray[id]
currentlySelectedItem = selectedSecondElement
}
}
}.rotation3DEffect(.init(degrees: 180), axis: (x: 0, y: 1, z: 0))
}
}
}
// You need to rotate the second VStack for it to expand in the correct direction (left).
// As now all text would be displayed as mirrored, you have to reverse that rotation "locally"
// with a .rotation3DEffect modifier (see 4 lines above).
.rotate3D()
.offset(x: resolveOffset(for: proxy))
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .topTrailing)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}.padding(10)
}
func resolveOffset(for proxy: GeometryProxy) -> CGFloat {
selectedSecondElement == nil ? proxy.size.width / 2 - padding / 2 : proxy.size.width
}
// Transform the original array to alternately contain nil and real values
// for the Color.clear views. You could just as well use other "default" values
// but I thought nil was quite explicit and makes it easier to understand what
// is going on. Then you split the transformed array into two sub-arrays for
// the VStacks:
func transformArray<T: Equatable>(array: [T]) -> ([T?], [T?]) {
var arrayTransformed : [T?] = []
array.map { element -> (T?, T?) in
return (nil, element)
}.forEach {
arrayTransformed.append($0.0)
arrayTransformed.append($0.1)
}
arrayTransformed = arrayTransformed.reversed()
var firstTransformedArray : [T?] = []
var secondTransformedArray : [T?] = []
for i in 0...arrayTransformed.count / 2 {
guard let nilValue = arrayTransformed.popLast(), let element = arrayTransformed.popLast() else { break }
if i % 2 == 0 {
firstTransformedArray += [nilValue, element]
} else {
secondTransformedArray += [nilValue, element]
}
}
return (firstTransformedArray, secondTransformedArray)
}
struct RectangleView: View {
let proxy: GeometryProxy
let elements : [T?]
let id: Int
let selectedElement : T?
let padding : CGFloat
let content : (T) -> Content
var body: some View {
content(elements[id]!)
.frame(width: calculateFrame(for: id), height: calculateFrame(for: id))
.clipShape(RoundedRectangle(cornerRadius: 20))
}
func calculateFrame(for id: Int) -> CGFloat {
selectedElement == elements[id] ? proxy.size.width : proxy.size.width / 2 - 5
}
}
}
extension View {
func rotate3D() -> some View {
modifier(StackRotation())
}
}
struct StackRotation: GeometryEffect {
func effectValue(size: CGSize) -> ProjectionTransform {
let c = CATransform3DIdentity
return ProjectionTransform(CATransform3DRotate(c, .pi, 0, 1, 0))
}
}

SwiftUI/PreferenceKey: How to avoid moving rects when scrolling?

I'm working on a tab bar that is scrollable and that has a moving background for the selected tab.
The solution is based on PreferenceKeys; however, I have a problem to get the moving background stable in relation to the tabs. Currently, it moves when scrolling, which is not desired; instead, it shall be fixed in relation to the tab item and scroll with them.
Why is this the case, and how to avoid that? When removing the ScrollView, the background moves correctly to the selected tab item. The TabItemButton is just a Button with some special label.
struct TabBar: View {
#EnvironmentObject var service: IRScrollableTabView.Service
// We support up to 15 items.
#State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 15)
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: geo.size.width))
.animation(.easeInOut(duration: 0.3))
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = selectedRect.minX + selectedRect.size.width / 2 - width / 2
debugPrint(selectedOffset)
return selectedOffset
}
}
struct Setter: View {
let index: Int
var body: some View {
GeometryReader { geo in
Rectangle()
.fill(Color.clear)
.preference(key: IRPreferenceKey.self,
value: [IRData(viewIndex: self.index,
rect: geo.frame(in: .named("IRReference")))])
}
}
}
struct IRPreferenceKey: PreferenceKey {
typealias Value = [IRData]
static var defaultValue: [IRScrollableTabView.IRData] = []
static func reduce(value: inout [IRScrollableTabView.IRData], nextValue: () -> [IRScrollableTabView.IRData]) {
value.append(contentsOf: nextValue())
}
}
struct IRData: Equatable {
let viewIndex: Int
let rect: CGRect
}
The service is defined this way (i.e., nothing special...):
final class Service: ObservableObject {
#Published var currentDestinationView: AnyView
#Published var tabItems: [IRScrollableTabView.Item]
#Published var selectedIndex: Int { didSet { debugPrint("selectedIndex: \(selectedIndex)") } }
init(initialDestinationView: AnyView,
tabItems: [IRScrollableTabView.Item],
initialSelectedIndex: Int) {
self.currentDestinationView = initialDestinationView
self.tabItems = tabItems
self.selectedIndex = initialSelectedIndex
}
}
struct Item: Identifiable {
var id: UUID = UUID()
var title: String
var image: Image = Image(systemName: "circle")
}
I solved the problem! The trick seemed to be to put another GeometryReader around the Indicator view and to take its width for calculating the offset. The .onPreferenceChange must be attached to the HStack, and the .coordinateSpace to the ZStack. Now it's working...
var body: some View {
GeometryReader { geo in
ScrollView(.horizontal) {
ZStack {
GeometryReader { innerGeo in
IRScrollableTabView.Indicator()
.frame(width: self.rects[self.service.selectedIndex].size.width,
height: self.rects[self.service.selectedIndex].size.height)
.offset(x: self.offset(width: innerGeo.size.width))
.animation(.easeInOut(duration: 0.3))
}
HStack(alignment: .top, spacing: 10) {
ForEach(0..<self.service.tabItems.count, id: \.self) { index in
TabItemButton(index: index,
isSelected: true,
item: self.service.tabItems[index])
// We want a fixed tab item with.
.frame(width: 70)
// This detects the effective positions of the tabs.
.background(IRTabItemViewSetter(index: index))
}
}
// Update the current tab positions.
.onPreferenceChange(IRTabItemPreferenceKey.self) { preferences in
debugPrint(">>> Preferences:")
for p in preferences {
debugPrint(p.rect)
self.rects[p.viewIndex] = p.rect
}
}
}
// We want to have the positions within this space.
.coordinateSpace(name: "IRReference")
}
}
}
private func offset(width: CGFloat) -> CGFloat {
debugPrint(width)
let selectedRect = self.rects[self.service.selectedIndex]
debugPrint(selectedRect)
let selectedOffset = -width / 2 + CGFloat(80 * self.service.selectedIndex) + selectedRect.size.width / 2
debugPrint(selectedOffset)
return selectedOffset
}