Pascal Boelck
Axel Springer Tech
Published in
8 min readNov 20, 2021

--

Hello everybody,

In this article, I will show you my implementation of a seamless View Carousel which can be used with all kinds of views.
There are a few key steps that we need to implement in order for this feature to work, such as positioning the views, reacting to user gestures, and adding the carousel behaviour. Finally, we also require fixing issues that could arise on iPads with the multitasking feature.

Step 1:

View positioning

The simplest step is to position all the views, for this we use a HStack and a simple foreach loop. These features may be pretty self-explanatory, but to go into a bit more depth here: the variable itemsCount is the number of views inside the view array. Feel free to add spacing between the individual views in the HStack via parameter if your design feels a bit squashed.

Step 2:

Positioning all the views

Initially we should know which views should be displayed in which order. Once we know this, we can calculate the width of our rotation view. This will be our pageWidth variable. Additionally, we also require the padding for all the views, which will be stored in our contentWidth variable.. The contentWidth is needed in order to determine the offset within the ForEach loop.
Now we are ready to position the first view. The formula is (pageWidth — tileWidth — tilePadding * 2) / 2 + tilePadding. The result of the value will be stored in the variable leadingOffset.
It is also necessary to determine and set the offset for the stack of the views in the center position. For that we have to use this formula (contentWidth — pageWidth — tilePadding) / 2 which will be stored in the stackOffset variable.
The last variable that we require is the currentScrollOffset to position the selected view. This variable can be zero at the beginning.
I opted to perform these initial calculations within the initializer.

After the initialization of all the variables, we have to create a function to calculate the position of the selectedItem. The name of the function is offsetForTileIndex.

Now that we are done with the calculations, we can position the views..
The views inside the Foreach get the offset out of the currentScrollOffset variable, While the Stack of the views gets the offset out of the stackOffset.

You might be asking yourself, “when do we set the currentScrollOffset variable?!” Good question. We do this in the onAppear ViewModifier. This is also the place into which we pass the global int Binding variable, which was mentioned in the initializer code snippet.

Note
If you want to create a vertical rotation, you have to change the offset values. This can be achieved by simply switching the x and y values.
Don’t forget to change the HStack into a VStack.

Step 3:

Response to swipe gesture

Before we start the implementation of the gesture, we add almost transparent background to the body. This allows us to recognize the swipe gesture even when the background is transparent.

We add the ViewModifier simultaneousGesture behind the background ViewModifier with DragGesture as the parameter. The DragGesture provides some functions to detect the position of the swipe gesture. First, let us concentrate on the onEnded function. The goal is to determine the selectedItem after the user lifts the finger. We use the property $0.predictedEndTranslation.width inside the onEnded function for this, and pass the sum of this value along with the currentScrollOffset to a new indexViewForOffset function.

The function gets the offset as CGFloat and subtracts from this the leadingOffset. If the first stack item is our start position, and we swipe from right to left, we get a negative value. This is why we have to multiply this value by -1. The positive value just has to be divided by the entire width of the view and padding to calculate the new Index. Now we can round and cast the value into an Integer type. To stay inside our bounds, we need to use the min-max functions which are provided by Swift. The results look like this.

The newly calculated index will be stored in the global selectedIndex variable. The selected view becomes the new position by setting the currentScrollOffset. Now we call the computedCurrentScrollOffset function again. The current implementation is very basic and lacks animation. Let’s put the setting of the currentScrollOffset variable inside a withAnimation block.

This has improved it slightly, but we can do better.. Let us add an animation during the swipe gesture…
To be informed about the individual steps during the swipe gesture, the onChanged function is required. The order of the functions onChanged and onEnded is not relevant. It works with both variants. Our goal is optical feedback of the swipe gesture regarding the views. For this, we need a new global variable with the name dragOffset.
This variable stores the $0.translation.width of the onChanged function and sets the currentScrollOffset variable to the result of computeCurrentScrollOffset(). The computeCurrentScrollOffset function gets the result of the offsetForTileIndex function and adds the dragOffset to it.

This is a good time to use the computeCurrentScrollOffset() to set the currentScrollOffest variable in the onEnded function. Subtract the dragOffset from the $0.predictedEndTranslation.width and multiply the result by 0.66 for better usability. The value 0.66 is a dumping factor to reduce the liveness. This value can be experimented with to determine the best behaviour. Last but not least, before you set the currentScrollOffset, reset the dragOffset.

This concludes step 3.

Your implementation just should look like this:

Step 4:

Simulate carousel behaviour.

To simulate the behaviour of a seamless carousel, we need to use a sneaky trick.
We require copying both the first and last views and append these to the opposite sides of the stack. For example, we copy the first views and put these on the end, and copy the end and place these before the first.

You have to look at how many views you require to copy. It doesn’t make sense to have a carousel with a huge visible background at the end of the stack.
The viewBuffer increases the selectedIndex variable by its value in the onAppear ViewModifier of the body.

Let’s get started with the second part of the sneaky trick. If the user swipes to the left side of our view stack and reaches the view Index 1 which is our last copied item. This is the point where we jump without any animation to the index of the last real view. The user doesn’t notice this jump, and if he swipes further to the left side, it gives the illusion of a seamless carousel. We do the same behaviour for the right side of our stack.

The setIndex function is an extraction of the setting of the selectedIndex and the currentScrollOffset variables. I do this to maintain clean code and reduce code duplications.

We are done with step 4.
Your implementation just should look like this:

Step 5:

Add a dot indicator for the view rotation

For this step, we need a VStack which wraps around our HStack with all the views in the Body property.

We will position the dot indicator below the view rotation and implement the corresponding code after all the ViewModifiers that we required for the view rotation.
The HStack includes a ForEach loop and iterates through all the views, excluding our buffer views. There is a button inside the loop which changes the selectedIndex. A Rectangle presents the label of this button. I decided on the size of 10 x 10 of each dot. The ViewModifier foregroundColor can be used to give feedback about the selected index of the dots.

We are done with this step 5.
Your implementation just should look like this:

Step 6

Resolves fitting errors

Finally, let’s fix the problem that occurs after rotating the device or activating the slot view on an iPad.

But first, let’s try to understand why this issue occurs. Right now we have a fixed size set for the views, and that’s where the issue lies. The views never change, they always keep that one big view. It doesn’t matter if it is larger than the available frame or not.

We need to figure out the size of the available space and adjust the views to fit that size. For this, we use the PreferenceKey.

But we cannot simply apply this to the view rotation. It would only return the value we set with the contentWidth variable. This requires a view that is in the background and has no effect on our rotation. I prefer a clear colour view as a background. We use a ZStack to place the colour as a background for the rotation. This colour includes a background with a GeometryReader, which is used to determine the size of the available space. Every time we change this size, we determine and recalculate the following variables.

The calculation of the variables contentWidth, leadingOffset, and stackOffset can be safely taken out of the initializer with this change. Also, the initializer parameter pageWidth can be removed.

We get the next problem. The just placed colour extends over the same large as the view rotation. This is logical, the ZStack adjusts to the size of its child view and so does our just created colour. The view rotation with its full size must remain, but must not have any effect on all other elements.

This is logical, the ZStack adjusts to the size of its child view and so does our just created colour. The view rotation with its full size must remain, but must not have any effect on all other elements.

An overlay encapsulates the view rotation while retaining full functionality. The outer ZStack will be removed and the colour with the GeometryReader will be the base of this view. The ViewRotation will be added as an overlay to the colour.

We are done with this step 6.
Your implementation just should look like this:

Congratulations, the carousel view rotation is ready.
In the next steps, you could adjust the corresponding parameters for a vertical Scroll behaviour.
Or you implement a paging scroll behaviour.
There are a lot of possibilities to advance this implementation — it is totally up to you. With these improvements, your possibilities have increased.

Conclusion

Thank you for reading my article, I hope you found it helpful. If you have any queries or suggestions, feel free to comment below, and I’ll answer them as soon as I can. Thanks.
The full code is also available on Github.

--

--