Creating Paging ScrollView using _VariadicView
Scroll paging effect in swiftUI using _VariadicView
Couple of days ago, I read a very interesting article written by
. This article talked about using _VariadicView swiftUI’s private api to create custom containers that feel natively built. Not just that it also allows you to do stuff (like adding custom set of modifiers) between the root and the leaf nodes of a container view.Link to the article:
After reading this article I kept thinking, how can I use _VariadicView 🤔? And just before sleep the idea clicked and told my self, Let’s create a custom paging scroll view using _VariadicView. For me this seemed like the perfect useCase for it.
Before we dive in to the problem, for the people who don’t know what is the _VariadicView, let me explain to you.
As
said in his post:_VariadicView is a private-ish SwiftUI API, and the underlying implementation detail for many of the container views you use every day.
It sounds pretty obscure, however its naming is actually pretty straightforward: it’s a view that can be passed any number of views, hence variadic.
_VariadicView lets you do one thing well:
Create reusable container components
…that can handle multiple types of child view passed in
…to which you can apply similar sets of modifiers.
And for all the people who may say it’s not safe to use its and will not make your app rejected or something. Due to the fact that it’s already used in many of the containers you use everyday like HStack, VStack and List.
Paging Scroll Effect
Now let’s first build the scroll paging effect, to be able to build it we need to visualize it first.
So as you see in the video above we want to have a scroll view where every time you scroll only one element is viewed and also in the center of the screen. So let’s take it step by step and build the HStack to display subViews horizontally.
HStack
First we will create a test view to include it in the stack and it will only contain a RoundedRectangle with corner radius 20:
@ViewBuilder
func testView() -> some View {
RoundedRectangle(cornerRadius: 20)
}
Then we will create HStack with a spacing of 16 between each view then will add a frame to the `testView()` like so:
HStack(spacing: 16) {
ForEach(1...10, id: \.self) { _ in
testView()
.frame(width: 250, height: 250)
}
}
The HStack have a small problem as you see, the first element in the stack isn’t centered as we wanted:
To solve this issue we can add horizontal padding to the HStack which will help center the view, but the question is How much ? 🤔
Let’s do some math so to make the view centered in the screen, we need to have equal spacing from the left and the right of the view.
To be able to get this value we will make a small calculation `(ScreenWidth - pageWidth
)/2` with this equation we will be able to have the right padding to center the view in the screen.
struct PagingScrollView: View {
let screenWidth = UIScreen.main.bounds.width
let pageWidth: CGFloat = 200
var body: some View {
HStack(spacing: 16) {
ForEach(1...10, id: \.self) { _ in
testView()
.frame(width: pageWidth, height: 250)
}
}
.padding(.horizontal, (screenWidth-pageWidth)/2)
}
}
But still after adding the padding to the screen it didn’t work, let’s see why ?
As you see from the preview the HStack expanded byeond the screen width, we want it to only be equal to the screen width, so let’s fix this.
struct PagingScrollView: View {
let screenWidth = UIScreen.main.bounds.width
let pageWidth: CGFloat = 200
var body: some View {
HStack(spacing: 16) {
ForEach(1...10, id: \.self) { _ in
testView()
.frame(width: pageWidth, height: 250)
}
}
.padding(.horizontal, (screenWidth-pageWidth)/2)
.frame(width: screenWidth, alignment: .leading)
}
}
As you see after we added the `.frame` to the view passing the `screenWidth`, the HStack doesn’t expand beyond the screen size. But you may ask why we made the alignment of the frame of HStack with `.leadign`.
The answer is simple because we don’t want the content inside the frame to be centered 😅 (which is the default value), because if the content in side the frame are centered to the frame, the subViews in the middle of the stack will appear first looking like this:
So to solve this and to show the first view in the stack first we made the alignment `.leadign`, which will make the alignment of the content inside the frame leading.
Now we have created the stack and adjusted its appearance which is the easy part 😉. Next step is making stack swappable.
Adding Drag Gesture
Next step to build paging effect is the dragGesture, since the user might swipe left or right we also need to know how to detect left and right swipe. Let’s do that by adding `.gesture` to our stack:
HStack(spacing: 16) {}
...
.gesture(
DragGesture()
.onEnded({ value in
if value.translation.width < -50 {
print("Swipe Right")
}
if value.translation.width > 50 {
print("Swipe Left")
}
})
)
From the code we can easily understand that any gesture in the right direction have a negative translation width value and left is the opposite simple right (not left 😂), now let’s see how can we show the next item in the stack 🤔.
Basically, what we need to do is to movie the stack to the left when the user swipes left and to the right when the user swipe right. To achieve this we will use `.offset` to movie the stack. To the next question how much should we offset the stack 🤔 ?
With some math we can figure it out by simply offsetting the stack with the `currentSelectedPage * -(pageWidth+spacing)`, let’s try it out.
Before we try it, an important note we will also need to decrement and increment when the user swipe left or right respectively.
Plus we will add `WithAnimation` to animate the change of the offset.
@State private var currentPage = 0
let screenWidth = UIScreen.main.bounds.width
let pageWidth: CGFloat = 200
var body: some View {
HStack(spacing: 16) {
ForEach(1...10, id: \.self) { _ in
testView()
.frame(width: pageWidth, height: 250)
}
}
.padding(.horizontal, (screenWidth-pageWidth)/2)
.offset(x: CGFloat(currentPage) * -(pageWidth + 16))
.frame(width: screenWidth, alignment: .leading)
.gesture(
DragGesture()
.onEnded({ value in
if value.translation.width < -50 {
withAnimation {
currentPage += 1
}
}
if value.translation.width > 50 {
withAnimation {
currentPage -= 1
}
}
})
)
}
But as you see there is a small problem the user can swipe to the left or right even after all the views where presented, let’s solve this by not incrementing the currentPage if it reached 9 (which is the pagesCount-1) and not decrementing the currentPage if the current page is 0, like so:
.....
.gesture(
DragGesture()
.onEnded({ value in
if value.translation.width < -50 {
withAnimation {
currentPage += currentPage == 9 ? 0 : 1
}
}
if value.translation.width > 50 {
withAnimation {
currentPage -= currentPage == 0 ? 0 : 1
}
}
})
Now we have a Horizontal paging scroll view, let’s go to the interesting part and wrap it in a nice api using _VariadicView.
Creating api using _VariadicView
First let’s create the init for the api we want to create.
struct HPagingScrollView<Content: View>: View {
@Binding var currentPage: Int
let spacing: CGFloat
let pageWidth: CGFloat
let pageHeight: CGFloat
@ViewBuilder let content: () -> Content
init(
currentPage: Binding<Int>,
spacing: CGFloat,
pageWidth: CGFloat,
pageHeight: CGFloat,
@ViewBuilder content: @escaping () -> Content
) {
_currentPage = currentPage
self.spacing = spacing
self.pageWidth = pageWidth
self.pageHeight = pageHeight
self.content = content
}
The `currentPage` represent the current page number the user is seeing, we created it as a binding because this value can be changed from our api and at the same time we want this change to be visible for the parent view. Then we have the spacing which represent the spacing between each page in the paging view, pageWidth and pageHeight explain them self’s 😅.
Now let’s create the body of this api where we will call the `_VariadicView`.
var body: some View {
_VariadicView.Tree(
_HPagingScrollViewRoot(
currentPage: $currentPage,
spacing: spacing,
pageWidth: pageWidth,
pageHeight: pageHeight
)
) {
content()
}
}
to be able to create our container we need to call `.Tree()` which is a struct that takes two things Root and Content.
@frozen
struct Tree<Root, Content> where Root : _VariadicView_Root
Content represent the number of subtrees (in other words subviews) passed to the container, the Root is the ViewBuilder where the logic live for building the view. As you see above I have created type that conforms to the `_VariadicView_
Root` which is `_HPagingScrollViewRoot`:
struct _HPagingScrollViewRoot: _VariadicView_MultiViewRoot {
@Binding var currentPage: Int
let spacing: CGFloat
let pageWidth: CGFloat
let pageHeight: CGFloat
private let screenWidth = UIScreen.main.bounds.width
func body(children: _VariadicView.Children) -> some View {}
}
`_VariadicView_MultiViewRoot` is simply a type that conforms to the `_VariadicView_
Root`, this is the same type the List uses to layout its content. `_VariadicView_MultiViewRoot` have only one required func which is body() where we will build our paging scroll view, it have on parameter which is children it basically represents a collection of the passed subViews to the container like so:
HStack {
Text("")
Text("")
Text("")
Text("")
}
// Children can be imagined as [Text(""), Text(""), Text(""), Text("")]
To build our container we will simply take the logic we built above and add it to the body method, replacing the `testView` with a forEach on the Children collection like so:
func body(children: _VariadicView.Children) -> some View {
HStack(spacing: spacing) {
ForEach(children) { child in
child
.frame(width: pageWidth, height: pageHeight)
.id(child.id)
}
}
.padding(.horizontal, (screenWidth-pageWidth)/2)
.offset(x: CGFloat(currentPage) * -(pageWidth+spacing))
.frame(width: screenWidth, alignment: .leading)
.gesture(scrollGesture(totalPages: children.count))
}
private func scrollGesture(totalPages: Int) -> some Gesture {
DragGesture()
.onEnded({ value in
if value.translation.width < -50 == false {
incrementCurrentPage(totalPages: totalPages-1)
}
if value.translation.width > 50 == false {
decrementCurrentPage()
}
})
}
private func incrementCurrentPage(totalPages: Int) {
withAnimation {
currentPage = currentPage == totalPages ? currentPage: currentPage+1
}
}
private func decrementCurrentPage() {
withAnimation {
currentPage = currentPage == 0 ? 0: currentPage-1
}
}
To use it we will call the `
HPagingScrollView` like so:
For the full code press me.
Conclusion
_VariadicView is a very powerful way to create your own swiftUI containers be creative with it there is no limits for how we can use it. Enjoy it 😉
For more resources:
https://movingparts.io/variadic-views-in-swiftui
https://substack.com/home/post/p-143381359?source=queue
Contact me thought email or iMessage if you needed any help omar@think-diffrent.com
See you in the next article, Made with ❤️ by Omar.
How does this compare to iOS 18's container view APIs? I thought that was the modern replacement for using _VariadicView, but not sure