Published on

Creating Paging ScrollView using _VariadicView

Authors
  • avatar
    Name
    Omar Elsayed
    Twitter

Introduction

Scroll paging effect in swiftUI using _VariadicView

#Preview {
    @Previewable @State var currentPage = 0

    // Api we want to create
    HPagingScrollView(currentPage: $currentPage, spacing: 16, pageWidth: 330, pageHeight: 150) {
        RoundedRectangle(cornerRadius: 20)
        RoundedRectangle(cornerRadius: 20)
        RoundedRectangle(cornerRadius: 20)
        RoundedRectangle(cornerRadius: 20)
    }
}

Couple of days ago, I read a very interesting article written by Jacob Bartlett. 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.

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.

_VariadicView

Before we dive in to the problem, for the people who don’t know what is the _VariadicView, let me explain to you.

As Jacob Bartlett 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.

Video link

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)
    }
}
xcode-view

The result of the above code, the HStack have a small problem as you see, the first element in the stack isn’t centered as we wanted:

xcode-view

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 ? 🤔

if you tried the above code, you will see from the preview that 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.

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.

NOTE

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:

#Preview {
    @Previewable @State var currentPage = 0

    // Api we want to create
    HPagingScrollView(currentPage: $currentPage, spacing: 16, pageWidth: 330, pageHeight: 150) {
        RoundedRectangle(cornerRadius: 20)
        RoundedRectangle(cornerRadius: 20)
        RoundedRectangle(cornerRadius: 20)
        RoundedRectangle(cornerRadius: 20)
    }
}

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 😉 subscribe for me like this, Contact me thought email or iMessage if you needed any help ♥️.

For more resources:

Subscribe for more