I am finding my first SwiftUI challenge to be a tricky one. Given a set of playing cards, display them in a way that allows the user to see the full deck while using space efficiently. Here's a simplified example:
In this case 52 cards (Views) are presented, in order of 01 - 52. They are dynamically packed into the parent view such that there is enough spacing between them to allow the numbers to be visible.
The problem
If we change the shape of the window, the packing algorithm will pack them (correctly) into a different number of rows & columns. However, when the number of rows/columns change, the card Views are out of order (some are duplicated):
In the image above, notice how the top row is correct (01 - 26) but the second row starts at 12 and ends at 52. I expect his is because the second row originally contained 12 - 22 and those views were not updated.
Additional criteria: The number of cards and the order of those cards can change at runtime. Also, this app must be able to be run on Mac, where the window size can be dynamically adjusted to any shape (within reason.)
I understand that when using ForEach for indexing, one must use a constant but I must loop through a series of rows and columns, each of which can change. I have tried adding id: \.self, but this did not solve the problem. I ended up looping through the maximum possible number of rows/columns (to keep the loop constant) and simply skipped the indices that I didn't want. This is clearly wrong.
The other alternative would be to use arrays of Identifiable structures. I tried this, but wasn't able to figure out how to organize the data flow. Also, since the packing is dependent on the size of the parent View it would seem that the packing must be done inside the parent. How can the parent generate the data needed to fulfill the deterministic requirements of SwiftUI?
I'm willing to do the work to get this working, any help understanding how I should proceed would be greatly appreciated.
The code below is a fully working, simplified version. Sorry if it's still a bit large. I'm guessing the problem revolves around the use of the two ForEach loops (which are, admittedly, a bit janky.)
import SwiftUI
// This is a hacked together simplfied view of a card that meets all requirements for demonstration purposes
struct CardView: View {
public static let kVerticalCornerExposureRatio: CGFloat = 0.237
public static let kPhysicalAspect: CGFloat = 63.5 / 88.9
#State var faceCode: String
func bgColor(_ faceCode: String) -> Color {
let ascii = Character(String(faceCode.suffix(1))).asciiValue!
let r = (CGFloat(ascii) / 3).truncatingRemainder(dividingBy: 0.7)
let g = (CGFloat(ascii) / 17).truncatingRemainder(dividingBy: 0.9)
let b = (CGFloat(ascii) / 23).truncatingRemainder(dividingBy: 0.6)
return Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
}
var body: some View {
GeometryReader { geometry in
RoundedRectangle(cornerRadius: 10)
.fill(bgColor(faceCode))
.cornerRadius(8)
.frame(width: geometry.size.height * CardView.kPhysicalAspect, height: geometry.size.height)
.aspectRatio(CardView.kPhysicalAspect, contentMode: .fit)
.overlay(Text(faceCode)
.font(.system(size: geometry.size.height * 0.1))
.padding(5)
, alignment: .topLeading)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 2))
}
}
}
// A single rows of our fanned out cards
struct RowView: View {
var cards: [String]
var width: CGFloat
var height: CGFloat
var start: Int
var columns: Int
var cardWidth: CGFloat {
return height * CardView.kPhysicalAspect
}
var cardSpacing: CGFloat {
return (width - cardWidth) / CGFloat(columns - 1)
}
var body: some View {
HStack(spacing: 0) {
// Visit all cards, but only add the ones that are within the range defined by start/columns
ForEach(0 ..< cards.count) { index in
if index < columns && start + index < cards.count {
HStack(spacing: 0) {
CardView(faceCode: cards[start + index])
.frame(width: cardWidth, height: height)
}
.frame(width: cardSpacing, alignment: .leading)
}
}
}
}
}
struct ContentView: View {
#State var cards: [String]
#State var fanned: Bool = true
// Generates the number of rows/columns that meets our rectangle-packing criteria
func pack(area: CGSize, count: Int) -> (rows: Int, cols: Int) {
let areaAspect = area.width / area.height
let exposureAspect = 1 - CardView.kVerticalCornerExposureRatio
let aspect = areaAspect / CardView.kPhysicalAspect * exposureAspect
var rows = Int(ceil(sqrt(Double(count)) / aspect))
let cols = count / rows + (count % rows > 0 ? 1 : 0)
while cols * (rows - 1) >= count { rows -= 1 }
return (rows, cols)
}
// Calculate the height of a card such that a series of rows overlap without covering the corner pips
func cardHeight(frameHeight: CGFloat, rows: Int) -> CGFloat {
let partials = CGFloat(rows - 1) * CardView.kVerticalCornerExposureRatio + 1
return frameHeight / partials
}
var body: some View {
VStack {
GeometryReader { geometry in
let w = geometry.size.width
let h = geometry.size.height
if w > 0 && h > 0 { // using `geometry.size != .zero` crashes the preview :(
let (rows, cols) = pack(area: geometry.size, count: cards.count)
let cardHeight = cardHeight(frameHeight: h, rows: rows)
let rowSpacing = cardHeight * CardView.kVerticalCornerExposureRatio
VStack(spacing: 0) {
// Visit all cards as if the layout is one row per card and simply skip the rows
// we're not interested in. If I make this `0 ..< rows` - it doesn't work at all
ForEach(0 ..< cards.count) { row in
if row < rows {
RowView(cards: cards, width: w, height: cardHeight, start: row * cols, columns: cols)
.frame(width: w, height: rowSpacing, alignment: .topLeading)
}
}
}
.frame(width: w, height: 100, alignment: .topLeading)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(cards: ["01", "02", "03", "04", "05", "06", "07", "08", "09",
"10", "11", "12", "13", "14", "15", "16", "17", "18", "19",
"20", "21", "22", "23", "24", "25", "26", "27", "28", "29",
"30", "31", "32", "33", "34", "35", "36", "37", "38", "39",
"40", "41", "42", "43", "44", "45", "46", "47", "48", "49",
"50", "51", "52"])
.background(Color.white)
.preferredColorScheme(.light)
}
}
I think you're on the right track that you need to use an Identifiable to prevent the system from making assumptions about what can be recycled in the ForEach. To that end, I've created a Card:
struct Card : Identifiable {
let id = UUID()
var title : String
}
Within the RowView, this is trivial to use:
struct RowView: View {
var cards: [Card]
var width: CGFloat
var height: CGFloat
var columns: Int
var cardWidth: CGFloat {
return height * CardView.kPhysicalAspect
}
var cardSpacing: CGFloat {
return (width - cardWidth) / CGFloat(columns - 1)
}
var body: some View {
HStack(spacing: 0) {
// Visit all cards, but only add the ones that are within the range defined by start/columns
ForEach(cards) { card in
HStack(spacing: 0) {
CardView(faceCode: card.title)
.frame(width: cardWidth, height: height)
}
.frame(width: cardSpacing, alignment: .leading)
}
}
}
}
In the ContentView, things get a little more complicated because of the dynamic rows:
struct ContentView: View {
#State var cards: [Card] = (1..<53).map { Card(title: "\($0)") }
#State var fanned: Bool = true
// Generates the number of rows/columns that meets our rectangle-packing criteria
func pack(area: CGSize, count: Int) -> (rows: Int, cols: Int) {
let areaAspect = area.width / area.height
let exposureAspect = 1 - CardView.kVerticalCornerExposureRatio
let aspect = areaAspect / CardView.kPhysicalAspect * exposureAspect
var rows = Int(ceil(sqrt(Double(count)) / aspect))
let cols = count / rows + (count % rows > 0 ? 1 : 0)
while cols * (rows - 1) >= count { rows -= 1 }
return (rows, cols)
}
// Calculate the height of a card such that a series of rows overlap without covering the corner pips
func cardHeight(frameHeight: CGFloat, rows: Int) -> CGFloat {
let partials = CGFloat(rows - 1) * CardView.kVerticalCornerExposureRatio + 1
return frameHeight / partials
}
var body: some View {
VStack {
GeometryReader { geometry in
let w = geometry.size.width
let h = geometry.size.height
if w > 0 && h > 0 { // using `geometry.size != .zero` crashes the preview :(
let (rows, cols) = pack(area: geometry.size, count: cards.count)
let cardHeight = cardHeight(frameHeight: h, rows: rows)
let rowSpacing = cardHeight * CardView.kVerticalCornerExposureRatio
VStack(spacing: 0) {
ForEach(Array(cards.enumerated()), id: \.1.id) { (index, card) in
let row = index / cols
if index % cols == 0 {
let rangeMin = min(cards.count, row * cols)
let rangeMax = min(cards.count, rangeMin + cols)
RowView(cards: Array(cards[rangeMin..<rangeMax]), width: w, height: cardHeight, columns: cols)
.frame(width: w, height: rowSpacing, alignment: .topLeading)
}
}
}
.frame(width: w, height: 100, alignment: .topLeading)
}
}
}
}
}
This loops through all of the cards and uses the unique IDs. Then, there's some logic to use the index to determine what row the loop is on and if it is the beginning of the loop (and thus should render the row). Finally, it sends just a subset of the cards to the RowView.
Note: you can look at Swift Algorithms for a more efficient method than enumerated. See indexed: https://github.com/apple/swift-algorithms/blob/main/Guides/Indexed.md
#jnpdx has provided a valid answer that is direct in its approach, which helps to understand the problem without adding additional complexity.
I have also stumbled across an alternative approach that requires more drastic changes to the structure of the code, but is more performant while also leading to more production-ready code.
To begin with, I created a CardData struct that implements the ObservableObject protocol. This includes the code to pack a set of cards into rows/columns based on a given CGSize.
class CardData: ObservableObject {
var cards = [[String]]()
var hasData: Bool {
return cards.count > 0 && cards[0].count > 0
}
func layout(cards: [String], size: CGSize) -> CardData {
// ...
// Populate `cards` with packed rows/columns
// ...
return self
}
}
This would only work if the layout code could know the frame size for which it was packing. To that end, I used .onChange(of:perform:) to track changes to the geometry itself:
.onChange(of: geometry.size, perform: { size in
cards.layout(cards: cardStrings, size: size)
})
This greatly simplifies the ContentView:
var body: some View {
VStack {
GeometryReader { geometry in
let cardHeight = cardHeight(frameHeight: geometry.size.height, rows: cards.rows)
let rowSpacing = cardHeight * CardView.kVerticalCornerExposureRatio
VStack(spacing: 0) {
ForEach(cards.cards, id: \.self) { row in
RowView(cards: row, width: geometry.size.width, height: cardHeight)
.frame(width: geometry.size.width, height: rowSpacing, alignment: .topLeading)
}
}
.frame(width: geometry.size.width, height: 100, alignment: .topLeading)
.onChange(of: geometry.size, perform: { size in
_ = cards.layout(cards: CardData.faceCodes, size: size)
})
}
}
}
In addition, it also simplifies the RowView:
var body: some View {
HStack(spacing: 0) {
ForEach(cards, id: \.self) { card in
HStack(spacing: 0) {
CardView(faceCode: card)
.frame(width: cardWidth, height: height)
}
.frame(width: cardSpacing, alignment: .leading)
}
}
}
Further improvements can be had by storing rows/columns of CardViews inside CardData rather than the card title strings. This will eliminate the need to recreate a full set of (in my case, complex) CardViews in the View code.
The final end result now looks like this:
Related
I'm using Phaser 3 and tried doing this:
class Bee extends Phaser.GameObjects.Sprite {
constructor(config) {
super(config.scene, config.x, config.y, 'bee');
config.scene.add.existing(this);
config.scene.physics.add.existing(this);
this.initial = [config.x, config.y];
this.x = config.x;
this.y = config.y;
this.speed = 5;
}
preUpdate() {
if (this.y < this.initial[1] - 100) {
this.speed *= -1;
}
if (this.y > this.initial[1] + 100) {
this.speed *= -1;
}
this.y += this.speed;
this.setY(this.y);
}
}
I want a Bee object to move down 100 px then move up and then repeat, but it doesn't seem to work.
In my scene I created a object:
bees = this.physics.add.group();
let bee = new Bee({scene:scene, x: 100, y:200})
bee.setScale(0.5);
bee.allowGravity = false;
bees.add(bee);
I'll have multiple Bee sprites in the game.
You could solve the up and down movement with a simple tween (https://photonstorm.github.io/phaser3-docs/Phaser.Tweens.Tween.html).
A tween changes/moves the selected property (or multiple properties) between a starting and an end position. In your case, I would use it to change the y property of the bee physic body velocity.
You can do alot with a tween, without the need to calculate speed, next position and so on. You just have to tweak the properties of the tween config, to get the desired movement/animation.
Here a working example:
(Update: Now with a few bees in the group and an 'offset' for the flight pattern)
class Bee extends Phaser.GameObjects.Sprite {
constructor(config) {
super(config.scene, config.x, config.y, 'bee');
config.scene.add.existing(this);
config.scene.physics.add.existing(this);
this.x = config.x;
this.y = config.y;
config.scene.tweens.add({
targets: this.body.velocity,
y: { from: -100, to: 100 },
ease: Phaser.Math.Easing.Quadratic.InOut,
yoyo: true,
repeat: -1,
duraton: 1000,
// just to create a interesting offset for the example
delay: Phaser.Math.Between(0,6) * 200
});
}
}
function create(){
let bees = this.physics.add.group();
for(let i=0; i < 10 ; i++){
let bee = new Bee({scene: this,
x: i * -100 ,
y: 50 });
bees.add(bee);
}
bees.setVelocity(100,0)
}
const config = {
type: Phaser.AUTO,
width: 400,
height: 200,
physics: {
default: 'arcade',
},
scene: {
create
}
};
const game = new Phaser.Game(config);
<script src="https://cdn.jsdelivr.net/npm/phaser#3.55.2/dist/phaser.js"></script>
I am trying to put grid(developed with scrollview) inside scrollview but I can't set grid height with content height. I can't handle this problem with .fixedsize()
GeometryReader{ reader in
ScrollView{
Text(reader.size.height.description)
Text(reader.size.height.description)
FlowStack(columns: 3, numItems: 27, alignment: .leading) { index, colWidth in
if index == 0{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
.background(Color.red)
}
else if index == 1{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
}
else if index == 2{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
}
else{
Text(" \(index) ").frame(width: colWidth).border(Color.gray)
}
}
.background(Color.red)
Text(reader.size.height.description)
Text(reader.size.height.description)
}.fixedSize(horizontal: false, vertical: false)
}
Result
What I want
I don't know what did you wrote in FlowStack struct, that's why it's really hard to give right answer. There is how I solve this grid:
struct GridScrollView: View {
var body: some View {
GeometryReader{ geometry in
VStack {
Text("height: \(geometry.size.height.description)")
Text("width: \(geometry.size.width.description)")
ScrollView{
FlowStack(columns: 3, numItems: 60, width: geometry.size.width, height: geometry.size.height)
}
}
}
}
}
my FlowStack code:
struct FlowStack: View {
var columns: Int
var numItems: Int
var width: CGFloat
var height: CGFloat
private var numberOfRows: Int {
get {
numItems / columns
}
}
private var cellWidth: CGFloat {
get {
width / CGFloat(columns) - 4 // with padding
}
}
private var cellHeight: CGFloat {
get {
height / CGFloat(numberOfRows) - 4 // with padding
}
}
var body: some View {
ForEach(0..<self.numberOfRows, id: \.self) { row in
HStack {
ForEach(1...self.columns, id: \.self) { col in
Text("\(row * self.columns + col)")
.frame(width: self.cellWidth, height: self.cellHeight, alignment: .center)
.background(Color.red)
.border(Color.gray)
.padding(2)
}
}
}
}
}
and the result is:
P.S. that's my quick solution. I think you can also use this grid from github, it's quite massive, but flexible in my opinion
I've embarrassingly spent the past 2 weeks trying to solve this.
What I'm trying to do is:
Snap my Slide Over View to the bottom of the screen
Disable dragging up and only allow the card to be dragged down to close
What I've tried:
I've tried messing with the size of the card by setting its height to the height of the screen. You can see this line commented out. After doing this, I've messed around with the offset of the card and set it so that it looks as if the card is actually less than half its size, around 300 in height. The problem with this is when I slide up slowly, I can see the empty space that is hidden out of the screen. This isn't the effect I want.
The next thing I have tried to do is change the height of the card to a desired height. Then adjust the offset so the card is where I want it to be. However, I feel manually adjusting it won't be reliable on different screens. So I'm trying to work out the right math needed to always have it be placed at the very bottom of the screen when it pops up.
Finally, I want to just make it so users can only drag down and not up.
I would really appreciate some help here. I've spent a lot of time message around and reading, learning new things, but I can't solve my specific problem.
Here is my Slide Over Card
import SwiftUI
struct SigninView<Content: View> : View {
#GestureState private var dragState = DragState.inactive
#State var position = CardPosition.top
var content: () -> Content
var body: some View {
let drag = DragGesture()
.updating($dragState) { drag, state, transaction in
state = .dragging(translation: drag.translation)
}
.onEnded(onDragEnded)
return Group {
// Handle()
self.content()
}
.frame(height: 333) //UIScreen.main.bounds.height)
.background(Color.purple)
.cornerRadius(10.0)
.shadow(color: Color(.sRGBLinear, white: 0, opacity: 0.13), radius: 10.0)
.offset(y: self.position.rawValue + self.dragState.translation.height)
.animation(self.dragState.isDragging ? nil : .interpolatingSpring(stiffness: 300.0, damping: 30.0, initialVelocity: 10.0))
.gesture(drag)
}
private func onDragEnded(drag: DragGesture.Value) {
let verticalDirection = drag.predictedEndLocation.y - drag.location.y
let cardTopEdgeLocation = self.position.rawValue + drag.translation.height
let positionAbove: CardPosition
let positionBelow: CardPosition
let closestPosition: CardPosition
if cardTopEdgeLocation <= CardPosition.middle.rawValue {
positionAbove = .top
positionBelow = .middle
} else {
positionAbove = .middle
positionBelow = .bottom
}
if (cardTopEdgeLocation - positionAbove.rawValue) < (positionBelow.rawValue - cardTopEdgeLocation) {
closestPosition = positionAbove
} else {
closestPosition = positionBelow
}
if verticalDirection > 0 {
self.position = positionBelow
} else if verticalDirection < 0 {
self.position = positionAbove
} else {
self.position = closestPosition
}
}
}
enum CardPosition: CGFloat {
case top = 100
case middle = 790
case bottom = 850
}
enum DragState {
case inactive
case dragging(translation: CGSize)
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return translation
}
}
var isDragging: Bool {
switch self {
case .inactive:
return false
case .dragging:
return true
}
}
}
Here is my ContentView page, where I test it:
import SwiftUI
struct ContentView: View {
#State var show:Bool = false
var body: some View {
SigninView {
VStack {
Text("TESTING")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.blue)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
First, you should probably not have your SigninView be your content view. Instead, consider presenting your sign in view as an overlay instead.
var body: some View {
ZStack {
Text("Content here!")
}
.overlay(
SigninView()
.offset(...),
alignment: .bottom
)
}
This will automatically place your view at the bottom of the screen at the height of your SigninView, there should be little to no math involved here. The offset, you will define with your gesture and any space you want to exist between the bottom and your overlay.
Next, to only allow down gestures, can't you just clamp your translation?
var translation: CGSize {
switch self {
case .inactive:
return .zero
case .dragging(let translation):
return max(0, translation) // clamp this to the actual translation or 0 so it can't go negative
}
}
Problem
How can I modify the scroll target of a scrollView? I am looking for kind of a replacement for the "classic" scrollView delegate method
override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
...where we can modfify the targeted scrollView.contentOffset via targetContentOffset.pointee for instance to create a custom paging behaviour.
Or in other words: I do want to create a paging effect in a (horizontal) scrollView.
What I have tried ie. is something like this:
ScrollView(.horizontal, showsIndicators: true, content: {
HStack(alignment: VerticalAlignment.top, spacing: 0, content: {
card(title: "1")
card(title: "2")
card(title: "3")
card(title: "4")
})
})
// 3.
.content.offset(x: self.dragState.isDragging == true ? self.originalOffset : self.modifiedOffset, y: 0)
// 4.
.animation(self.dragState.isDragging == true ? nil : Animation.spring())
// 5.
.gesture(horizontalDragGest)
Attempt
This is what I tried (besides a custom scrollView approach):
A scrollView has a content area larger then screen space to enable scrolling at all.
I created a DragGesture() to detect if there is a drag going on. In the .onChanged and .onEnded closures I modified my #State values to create a desired scrollTarget.
Conditionally fed in both the original unchanged and the new modified values into the .content.offset(x: y:) modifier - depending on the dragState as a replacement for missing scrollDelegate methods.
Added animation acting conditionally only when drag has ended.
Attached the gesture to the scrollView.
Long story short. It doesn't work.
I hope I got across what my problem is.
Any solutions out there? Looking forward to any input. Thanks!
I have managed to achieve a paging behaviour with a #Binding index. The solution might look dirty, I'll explain my workarounds.
The first thing I got wrong, was to get alignment to .leading instead of the default .center, otherwise the offset works unusual. Then I combined the binding and a local offset state. This kinda goes against the "Single source of truth" principle, but otherwise I had no idea how to handle external index changes and modify my offset.
So, my code is the following
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)
.gesture(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 }
}))
}
}
}
you may just wrap your content, I used it for "Tutorial Views".
this a trick to switch between external and internal state changes
.leading is mandatory if you don't want to translate all offsets to center.
set the state to local state change
calculate the full offset from the gesture delta (*-1) plus the previous index state
at the end set the final index based on the gesture predicted end, while rounding the offset up or down
reset the state to handle external changes to index
I have tested it in the following context
struct WrapperView: View {
#State var index: Int = 0
var body: some View {
VStack {
SwiftUIPagerView(index: $index, pages: (0..<4).map { index in TODOView(extraInfo: "\(index + 1)") })
Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
}
.pickerStyle(SegmentedPickerStyle())
.padding()
}
}
}
where TODOView is my custom view that indicates a view to implement.
I hope I get the question right, if not please specify which part should I focus on. Also I welcome any suggestions to remove the isGestureActive state.
#gujci your solution is perfect, for more general usage, make it accept Models and view builder as in (note the I pass the geometry size in the builder) :
struct SwiftUIPagerView<TModel: Identifiable ,TView: View >: View {
#Binding var index: Int
#State private var offset: CGFloat = 0
#State private var isGestureActive: Bool = false
// 1
var pages: [TModel]
var builder : (CGSize, TModel) -> TView
var body: some View {
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .center, spacing: 0) {
ForEach(self.pages) { page in
self.builder(geometry.size, page)
}
}
}
// 2
.content.offset(x: self.isGestureActive ? self.offset : -geometry.size.width * CGFloat(self.index))
// 3
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(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 }
}))
}
}
}
and can be used as :
struct WrapperView: View {
#State var index: Int = 0
#State var items : [(color:Color,name:String)] = [
(.red,"Red"),
(.green,"Green"),
(.yellow,"Yellow"),
(.blue,"Blue")
]
var body: some View {
VStack(spacing: 0) {
SwiftUIPagerView(index: $index, pages: self.items.identify { $0.name }) { size, item in
TODOView(extraInfo: item.model.name)
.frame(width: size.width, height: size.height)
.background(item.model.color)
}
Picker(selection: self.$index.animation(.easeInOut), label: Text("")) {
ForEach(0..<4) { page in Text("\(page + 1)").tag(page) }
}
.pickerStyle(SegmentedPickerStyle())
}.edgesIgnoringSafeArea(.all)
}
}
with the help of some utilities :
struct MakeIdentifiable<TModel,TID:Hashable> : Identifiable {
var id : TID {
return idetifier(model)
}
let model : TModel
let idetifier : (TModel) -> TID
}
extension Array {
func identify<TID: Hashable>(by: #escaping (Element)->TID) -> [MakeIdentifiable<Element, TID>]
{
return self.map { MakeIdentifiable.init(model: $0, idetifier: by) }
}
}
#gujci, thank you for interesting example. I've played with it and removed the isGestureActive state. Full example may be found in my gist.
struct SwiftUIPagerView<Content: View & Identifiable>: View {
#State private var index: Int = 0
#State private var offset: CGFloat = 0
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)
}
}
}
.content.offset(x: self.offset)
.frame(width: geometry.size.width, height: nil, alignment: .leading)
.gesture(DragGesture()
.onChanged({ value in
self.offset = value.translation.width - geometry.size.width * CGFloat(self.index)
})
.onEnded({ value in
if abs(value.predictedEndTranslation.width) >= geometry.size.width / 2 {
var nextIndex: Int = (value.predictedEndTranslation.width < 0) ? 1 : -1
nextIndex += self.index
self.index = nextIndex.keepIndexInRange(min: 0, max: self.pages.endIndex - 1)
}
withAnimation { self.offset = -geometry.size.width * CGFloat(self.index) }
})
)
}
}
}
As far as I know scrolls in swiftUI doesn't support anything potentially useful such as scrollViewDidScroll or scrollViewWillEndDragging yet. I suggest using either classic UIKit views for making very custom behavior and cool SwiftUI views for anything that is easier. I've tried that a lot and it actually works! Have a look at this guide. Hope that helps
Alternative solution would be to integrate UIKit into SwiftUI using UIViewRepresentative which links UIKit components with SwiftUI. For additional leads and resources, see how Apple suggests you interface with UIKit: Interfacing with UIKit. They have a good example that shows to page between images and track selection index.
Edit: Until they (Apple) implement some sort of content offset that effects the scroll instead of the entire view, this is their suggested solution since they knew the initial release of SwiftUI wouldn't encompass all functionality of UIKit.
Details
Xcode 14
Swift 5.6.1
Requirements
I do not want to use integration with UIKit (clean SwiftUI ONLY)
I do not want to scroll to the any "ID", I want to scroll to the point
Solution
import SwiftUI
#available(iOS 14.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
struct ExtendedScrollView<Content>: View where Content: View {
private let contentProvider: _AligningContentProvider<Content>
// Main Idea from: https://github.com/edudnyk/SolidScroll/blob/main/Sources/SolidScroll/ScrollView.swift
private var config: _ScrollViewConfig
init(config: _ScrollViewConfig = _ScrollViewConfig(),
#ViewBuilder content: () -> Content) {
contentProvider = _AligningContentProvider(content: content(), horizontal: .center, vertical: .center)
self.config = config
}
init(_ axes: Axis.Set = .vertical,
showsIndicators: Bool = true,
#ViewBuilder content: () -> Content) {
var config = _ScrollViewConfig()
config.showsHorizontalIndicator = axes.contains(.horizontal) && showsIndicators
config.showsVerticalIndicator = axes.contains(.vertical) && showsIndicators
self.init(config: config, content: content)
}
init(config: () -> _ScrollViewConfig,
#ViewBuilder content: () -> Content) {
self.init(config: config(), content: content)
}
var body: some View {
_ScrollView(contentProvider: contentProvider, config: config)
}
}
extension _ContainedScrollViewKey: PreferenceKey {}
// MARK: Track ScrollView Scrolling
struct TrackableExtendedScrollView: ViewModifier {
let onChange: (_ScrollViewProxy?) -> Void
func body(content: Content) -> some View {
content
.onPreferenceChange(_ContainedScrollViewKey.self, perform: onChange)
}
}
extension View {
func onScrollChange(perform: #escaping (_ScrollViewProxy?) -> Void) -> some View {
modifier(TrackableExtendedScrollView(onChange: perform))
}
}
Usage Sample
private var gridItemLayout = (0..<40).map { _ in
GridItem(.fixed(50), spacing: 0, alignment: .leading)
}
// ....
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout) {
ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
Text("\(index)")
.frame(width: 50)
.frame(maxHeight: .infinity)
}
}
}
.onScrollChange { proxy in
// let offset = proxy?.contentOffset.y
}
Full Sample
Implementation details
First column and first row are always on the screen
There are 3 "CollectionView":
first row "CollectionView"
first column "CollectionView"
main content "CollectionView"
All "CollectionView" are synced (if you scroll one "CollectionView", another will also be scrolled)
Do not forget to paste The Solution code here
import SwiftUI
import Combine
struct ContentView: View {
private let columWidth: CGFloat = 50
private var gridItemLayout0 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
private var gridItemLayout1 = [GridItem(.fixed(50), spacing: 0, alignment: .leading)]
private var gridItemLayout = (0..<40).map { _ in
GridItem(.fixed(50), spacing: 0, alignment: .leading)
}
#State var text: String = "scrolling not detected"
#State private var scrollViewProxy1: _ScrollViewProxy?
#State private var tableContentScrollViewProxy: _ScrollViewProxy?
#State private var tableHeaderScrollViewProxy: _ScrollViewProxy?
private let numberOfColumns = 50
private let numberOfRows = 40
let headerColor = Color(0xEE9B00)
let firstColumnColor = Color(0x0A9396)
let headerTextColor = Color(.white)
let horizontalSpacing: CGFloat = 6
let verticalSpacing: CGFloat = 0
let firstColumnWidth: CGFloat = 100
let columnWidth: CGFloat = 60
var body: some View {
VStack(spacing: 0) {
Text("First column and row are sticked to the content")
.foregroundColor(.gray)
Text(text)
HStack {
Rectangle()
.frame(width: firstColumnWidth-2)
.foregroundColor(.clear)
buildFirstCollectionViewRow()
}
.frame(height: 50)
HStack(alignment: .firstTextBaseline, spacing: horizontalSpacing) {
buildFirstCollectionViewColumn()
buildCollectionViewContent()
}
}
}
#ViewBuilder
private func buildFirstCollectionViewRow() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout1, spacing: horizontalSpacing) {
ForEach((0..<numberOfColumns), id: \.self) {
let color = $0%2 == 0 ? Color(0x005F73) : Color(0xCA6702)
Text("Value\($0)")
.frame(width: columnWidth)
.frame(maxHeight: .infinity)
.foregroundColor(headerTextColor)
.background(color)
.font(.system(size: 16, weight: .semibold))
}
}
}
.onScrollChange { proxy in
if tableHeaderScrollViewProxy != proxy { tableHeaderScrollViewProxy = proxy }
guard proxy?.isScrolling ?? false else { return }
if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
let offset = proxy?.contentOffset.x {
tableContentScrollViewProxy?.contentOffset.x = offset
}
text = "scrolling: header"
}
}
}
// MARK: Collection View Elements
extension ContentView {
#ViewBuilder
private func buildFirstCollectionViewColumn() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
ForEach((0..<numberOfRows), id: \.self) {
Text("multi line text \($0)")
.foregroundColor(.white)
.lineLimit(2)
.frame(width: firstColumnWidth)
.font(.system(size: 16, weight: .semibold))
.frame(maxHeight: .infinity)
.background(firstColumnColor)
.border(.white)
}
}
}
.frame(width: firstColumnWidth)
.onScrollChange { proxy in
if scrollViewProxy1 != proxy { scrollViewProxy1 = proxy }
guard proxy?.isScrolling ?? false else { return }
if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
let offset = proxy?.contentOffset.y {
tableContentScrollViewProxy?.contentOffset.y = offset
}
text = "scrolling: 1st column"
}
}
#ViewBuilder
private func buildCollectionViewContent() -> some View {
ExtendedScrollView() {
LazyHGrid(rows: gridItemLayout, spacing: horizontalSpacing) {
ForEach((0..<numberOfRows*numberOfColumns), id: \.self) { index in
let color = (index/numberOfRows)%2 == 0 ? Color(0x94D2BD) : Color(0xE9D8A6)
Text("\(index)")
.frame(width: columnWidth)
.frame(maxHeight: .infinity)
.background(color)
.border(.white)
}
}
}
.onScrollChange { proxy in
if tableContentScrollViewProxy != proxy { tableContentScrollViewProxy = proxy }
guard proxy?.isScrolling ?? false else { return }
if scrollViewProxy1?.contentOffset.y != tableContentScrollViewProxy?.contentOffset.y,
let offset = proxy?.contentOffset.y {
self.scrollViewProxy1?.contentOffset.y = offset
}
if tableHeaderScrollViewProxy?.contentOffset.x != tableContentScrollViewProxy?.contentOffset.x,
let offset = proxy?.contentOffset.x {
self.tableHeaderScrollViewProxy?.contentOffset.x = offset
}
text = "scrolling: content"
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension Color {
init(_ hex: UInt, alpha: Double = 1) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: alpha
)
}
}
Full Sample Demo
guys I know this question has been asked several times, several different ways, but I just can get it to work. Basically I have 2d clouds, but I want the camera to rotate around an object floating above the clouds. The problem is, when im not looking a the face of the clouds u can tell that they are 2d. Soooo i want the the clouds to "look" at the camera where ever it is. I believe my problem stems from how the cloud geometry is called on to the planes, but here take a look. I put the a lookAt function with in my animate function. I hope you can point me in the right direction at least.
Three.js rev. 70...
container.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
camera.position.set(0, 0, 100);
scene.add(camera);
controls = new THREE.OrbitControls( camera );
controls.target.copy( new THREE.Vector3( 0, 0,475) );
controls.minDistance = 50;
controls.maxDistance = 200;
controls.autoRotate = true;
controls.autoRotateSpeed = .2; // 30 seconds per round when fps is 60
controls.minPolarAngle = Math.PI/4; // radians
controls.maxPolarAngle = Math.PI/2; // radians
controls.enableDamping = true;
controls.dampingFactor = 0.25;
clock = new THREE.Clock();
cloudGeometry = new THREE.Geometry();
var texture = THREE.ImageUtils.loadTexture('img/cloud10.png', null, animate);
texture.magFilter = THREE.LinearMipMapLinearFilter;
texture.minFilter = THREE.LinearMipMapLinearFilter;
var fog = new THREE.Fog(0x4584b4, -100, 3000);
cloudMaterial = new THREE.ShaderMaterial({
uniforms: {
"map": {
type: "t",
value: texture
},
"fogColor": {
type: "c",
value: fog.color
},
"fogNear": {
type: "f",
value: fog.near
},
"fogFar": {
type: "f",
value: fog.far
},
},
vertexShader: document.getElementById('vs').textContent,
fragmentShader: document.getElementById('fs').textContent,
depthWrite: false,
depthTest: false,
transparent: true
});
var plane = new THREE.Mesh(new THREE.PlaneGeometry(64, 64));
for (var i = 0; i < 8000; i++) {
plane.position.x = Math.random() * 1000 - 500;
plane.position.y = -Math.random() * Math.random() * 200 - 15;
plane.position.z = i;
plane.rotation.z = Math.random() * Math.PI;
plane.scale.x = plane.scale.y = Math.random() * Math.random() * 1.5 + 0.5;
plane.updateMatrix();
cloudGeometry.merge(plane.geometry, plane.matrix);
}
cloud = new THREE.Mesh(cloudGeometry, cloudMaterial);
scene.add(cloud);
cloud = new THREE.Mesh(cloudGeometry, cloudMaterial);
cloud.position.z = -8000;
scene.add(cloud);
var radius = 100;
var xSegments = 50;
var ySegments = 50;
var geo = new THREE.SphereGeometry(radius, xSegments, ySegments);
var mat = new THREE.ShaderMaterial({
uniforms: {
lightPosition: {
type: 'v3',
value: light.position
},
textureMap: {
type: 't',
value: THREE.ImageUtils.loadTexture("img/maps/moon.jpg")
},
normalMap: {
type: 't',
value: THREE.ImageUtils.loadTexture("img/maps/normal.jpg")
},
uvScale: {
type: 'v2',
value: new THREE.Vector2(1.0, 1.0)
}
},
vertexShader: document.getElementById('vertexShader').textContent,
fragmentShader: document.getElementById('fragmentShader').textContent
});
mesh = new THREE.Mesh(geo, mat);
mesh.geometry.computeTangents();
mesh.position.set(0, 50, 0);
mesh.rotation.set(0, 180, 0);
scene.add(mesh);
}
function onWindowResize() {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
}
function animate() {
requestAnimationFrame(animate);
light.orbit(mesh.position, clock.getElapsedTime());
cloud.lookAt( camera );
controls.update(camera);
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', onWindowResize, false);
just a first guess:
the lookAt function needs Vector3 as parameter. try to use camera.position in the animate function.
cloud.lookAt( camera.position );
first of all, to build 2D objects in a scene that always faces towards the camera, you should use Sprite object, so you don't have to do anything to get this effect. (and have better performance :))
Definition from THREE.org: Sprite - a sprite is a plane in an 3d scene which faces always towards the camera.
var map = THREE.ImageUtils.loadTexture( "sprite.png" );
var material = new THREE.SpriteMaterial( { map: map, color: 0xffffff, fog: true } );
var sprite = new THREE.Sprite( material );
scene.add( sprite );
Please check this example: http://threejs.org/examples/#webgl_points_sprites
I would absolutely agree, I would use Sprite, or even Points, but then, if assign a texture to it, it will render it square-sized. My sprites are animated, and frames cannot be packed in square tiles, cause it would take a lot of space. I might make a mesh and use this lookAt function.