I'm having problems laying out a VStack in SwiftUI that uses a custom pager controller, and despite trying loads of options I can't get it to behave the way I want.
Sample code:
import SwiftUI
struct ContentView: View {
@State var pageIndex: Int = 0
var body: some View {
VStack {
Text("Headline").font(.title)
Divider()
pagedView.background(Color.pink).layoutPriority(1)
Color.red
Color.blue
Color.gray
}
}
var pagedView: some View {
PagerManager(pageCount: 2, currentIndex: $pageIndex) {
VStack {
Text("Line 1")
Text("Line 2")
Text("Line 3")
Text("Line 4")
Text("Line 5")
Text("Line 6")
Text("Line 7")
Text("Line 8")
}
VStack {
Text("Line 21")
Text("Line 22")
Text("Line 23")
Text("Line 24")
Text("Line 25")
}
}
}
}
struct PagerManager<Content: View>: View {
let pageCount: Int
@Binding var currentIndex: Int
let content: Content
//Set the initial values for the variables
init(pageCount: Int, currentIndex: Binding<Int>, @ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self._currentIndex = currentIndex
self.content = content()
}
@GestureState private var translation: CGFloat = 0
var body: some View {
VStack {
singlePageView.background(Color.yellow)
HStack(spacing: 8) {
ForEach(0 ..< self.pageCount, id: \.self) { index in
CircleButton(isSelected: Binding<Bool>(get: { self.currentIndex == index }, set: { _ in })) {
withAnimation {
self.currentIndex = index
}
}
}
}
}
}
var singlePageView: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
self.content.frame(width: geometry.size.width).background(Color.purple)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * geometry.size.width)
.offset(x: self.translation)
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
struct CircleButton: View {
@Binding var isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: {
self.action()
}) { Circle()
.overlay(
Circle()
.stroke(Color.black,lineWidth: 1)
).foregroundColor(self.isSelected ? Color(UIColor.systemGray2) : Color.white.opacity(0.5))
.frame(width: 10, height: 10)
}
}
}
What I'm trying to achieve is for pagedView to get priority for it's own space and then the rest of the VStack views (red, blue, gray in the code) to have the remaining space divided equally between them.
Without layoutPriority(1) on pagedView the vertical space is divided equally between the 4 views (as expected), which does not leave enough space for the Text lines in the pagedView which are squashed into the bottom of the view. (Background colors added to show how the layout is working)

However, adding layoutPriority(1) on the pageView makes the embedded singlePageView (in yellow) grab all of the vertical space so the remaining Colors are not shown.

The issue seems to be somewhere in the singlePageView code, as changing singlePageView.background(Color.yellow) to content in PagerManager VStack means the layout behaves as I want (without the desired paging of course):

I know I could take a different approach and try wrapping a UIPageViewController in a UIViewControllerRepresentable but before I go down that route I'd like to know if there's a way of getting the pure SwiftUI approach to work
I'm not sure if your question is still an open issue for you, but for everybody else with a similar problem, the following explanation might help:
By using the layoutPriority modifier, you are asking SwiftUI to start laying out the body views of the ContentViews VStack with the singlePageView. This means that this view now gets the full available space offered. As you use the GeometryReader as your outermost view, it will claim the maximum space available no space leaving to all the remaining views. GeometryReader is like Color a view which will always occupy the maximum available space offered by the parent view.
(For more information about the SwiftUI layout process, read for example How layout works in SwiftUI from HackingWithSwiftUI.)
If you remove the layoutPriority modifier, all the views are equally important to the parent and therefore each of them will get a quarter of the available space. This means for your singlePageView that it is not enough to show it's full content: The GeometryReader could only claim a quarter of the available height.
Your main goal is, that the PagerManager is occupying the full horizontal screen space available but uses only the minimum required vertical space. You should therefore not use the GeometryReader as your main view in singlePageView as this will influence how its content will be laid out. But nevertheless you have to size the content of the page view to occupy the full width.
What can you do get your desired layout?
pageWidth in PagerManager:@State private var pageWidth: CGFloat = 600
GeometryReader on the background of the circle buttons parent HStack (the .frame(maxWidth: .infinity) modifier ensures that it occupies the whole available screen width):HStack(spacing: 8) {
ForEach(0 ..< self.pageCount, id: \.self) { index in
// ... omitted CircleButton code
}
}
// Measure the width of the screen using GeometryReader on the background
.frame(maxWidth: .infinity)
.background(
GeometryReader { proxy -> Color in
DispatchQueue.main.async {
pageWidth = proxy.size.width
}
return Color.clear
}
)
singlePageView remove the GeometryReader and replace all occurrences of geometry.size.width with pageWidth:var singlePageView: some View {
HStack(spacing: 0) {
self.content.frame(width: pageWidth).background(Color.purple)
}
.frame(width: pageWidth, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * pageWidth)
.offset(x: self.translation)
.animation(.interactiveSpring())
.contentShape(Rectangle()) // ensures we can drag on the transparent background
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / pageWidth
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
As you can see I've added .contentShape(Rectangle()) modifier to ensure that the DragGesture also works on the empty background.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With