In this blog post, we will go through the process of building a custom onboarding swiper using the MVVM architecture and UICollectionView. This method will give us greater control over the design and functionality of the swiper, allowing us to make it more engaging and user-friendly. The final result is showcased in the video below.
The first step is to set up the project. We will be using Cocoapods for this project. We will use PureLayout to quickly add constraints and AdvancedPageControl for beautiful page indicators that work great with a scroll view that we will implement.
If you haven't used CocoaPods before, you can install it by following the instructions available on their website. After that, you need to create a Podfile and add the following code:
# Uncomment the next line to define a global platform for your project
platform :ios, '13.0'
def shared_pods
pod 'PureLayout'
pod 'AdvancedPageControl'
pod 'RxSwift'
pod 'RxCocoa'
end
target 'MVVMOnboarding' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
shared_pods
end
Note: If you have multiple targets, using shared_pods function you can easily add new pods to all targets.
Once you have added the above code to your Podfile, move to the location of the Podfile in Terminal and run pod install. After that, your project should be ready, and you can now open your .workspace file.
For this project, we will be using the MVVM architecture. First, we will create a model that holds all the texts for pages and images. Using an enum structure, we can easily add more pages later. Also, Page model conforms to CaseIterable to easily create an array from all cases.
import Foundation
enum Page: CaseIterable {
case pageZero
case pageOne
case pageTwo
var title: String {
switch self {
case .pageZero:
return "Welcome to MVVM Onboarding App"
case .pageOne:
return "Create beautiful onaboarding experience for your user"
case .pageTwo:
return "Smooth and interactive"
}
}
var image: String {
switch self {
case .pageZero:
return "page1"
case .pageOne:
return "page2"
case .pageTwo:
return "page3"
}
}
var index: Int {
switch self {
case .pageZero:
return 0
case .pageOne:
return 1
case .pageTwo:
return 2
}
}
}
Next, we create all the pages in the view model and add helper functions. Finally, all the views are in the view file.
import Foundation
import RxSwift
import RxCocoa
class OnboardingViewModel {
enum Event {
case registerClicked
case loginClicked
}
let events = PublishSubject<Event>()
let pages: BehaviorRelay<[Page]> = BehaviorRelay<[Page]>(value: [])
var index = 0
init() {
getPages()
}
func getPages(){
let allPages: [Page] = Page.allCases
self.pages.accept(allPages)
}
func getNumberOfPages() -> Int {
return self.pages.value.count
}
func getPageForRow(index: Int) -> Page {
return self.pages.value[index]
}
func getTitle(index: Int) -> String {
return pages.value[index].title
}
func getImage(index: Int) -> String {
return pages.value[index].image
}
func getPageIndex() -> Int {
return index
}
func registerClicked() {
events.onNext(.registerClicked)
}
func loginClicked() {
events.onNext(.loginClicked)
}
}
For images, we use https://undraw.co to get beautiful, modern images.
Every view will be made programmatically. We will first create a UICollectionView that will mimic a page swiper by enabling collectionView.isPagingEnabled to true and collectionView.decelerationRate set to UIScrollView.DecelerationRate.fast.
lazy var titleCollectionView: UICollectionView = {
let collectionViewLayout = UICollectionViewFlowLayout()
collectionViewLayout.minimumLineSpacing = .zero
collectionViewLayout.sectionInset = .zero
collectionViewLayout.minimumInteritemSpacing = .zero
collectionViewLayout.scrollDirection = .horizontal
let collectionView = UICollectionView(frame: view.frame, collectionViewLayout: collectionViewLayout)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.isScrollEnabled = true
collectionView.bounces = true
collectionView.backgroundColor = .clear
collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
collectionView.register(OnboardingCollectionViewCell.self, forCellWithReuseIdentifier: OnboardingCollectionViewCell.reuseIdentifier)
collectionView.contentInset = .zero
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.alwaysBounceHorizontal = false
collectionView.isPagingEnabled = true
return collectionView
}()
We will have two image views behind the UICollectionView that will fade depending on the scroll.
lazy var firstImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: viewModel.getImage(index: 0)))
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFit
return imageView
}()
lazy var secondImageView: UIImageView = {
let imageView = UIImageView(image: UIImage(named: viewModel.getImage(index: 1)))
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFit
return imageView
}()
Next, we will add page indicators from the AdvancedPageControl framework.
lazy var pageControlStackView: AdvancedPageControlView = {
let pageControl = AdvancedPageControlView()
pageControl.drawer = ExtendedDotDrawer(height: 8, width: 8, space: 8, raduis: 8, indicatorColor: .purple)
pageControl.numberOfPages = viewModel.getNumberOfPages()
return pageControl
}()
After creating all the views, we add them to the view and set up the constraints.
We set up the UICollectionView constraints from top to the paging indicators to be able to swipe on images, not just on text.
func setupUI() {
view.backgroundColor = .white
view.addSubview(firstImageView)
view.addSubview(secondImageView)
view.addSubview(titleCollectionView)
view.addSubview(createAccountButton)
view.addSubview(loginButton)
view.addSubview(pageControlStackView)
firstImageView.autoSetDimensions(to: CGSize(width: view.frame.width, height: UIScreen.main.bounds.height / 2.5 + 40))
firstImageView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
firstImageView.autoPinEdge(.bottom, to: .top, of: pageControlStackView, withOffset: 50)
firstImageView.autoAlignAxis(toSuperviewAxis: .vertical)
secondImageView.autoSetDimensions(to: CGSize(width: view.frame.width, height: UIScreen.main.bounds.height / 2.5 + 40))
secondImageView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
secondImageView.autoPinEdge(.bottom, to: .top, of: pageControlStackView, withOffset: 50)
secondImageView.autoAlignAxis(toSuperviewAxis: .vertical)
view.sendSubviewToBack(secondImageView)
titleCollectionView.autoAlignAxis(toSuperviewAxis: .vertical)
titleCollectionView.autoPinEdge(.bottom, to: .top, of: createAccountButton, withOffset: -100)
titleCollectionView.autoPinEdge(toSuperviewEdge: .left)
titleCollectionView.autoPinEdge(toSuperviewEdge: .right)
titleCollectionView.autoPinEdge(toSuperviewEdge: .top)
view.bringSubviewToFront(titleCollectionView)
pageControlStackView.autoPinEdge(.bottom, to: .top, of: createAccountButton, withOffset: -30)
pageControlStackView.autoAlignAxis(toSuperviewAxis: .vertical)
pageControlStackView.autoSetDimensions(to: CGSize(width: view.frame.width, height: 30))
createAccountButton.autoPinEdge(.bottom, to: .top, of: loginButton, withOffset: -10)
createAccountButton.autoSetDimensions(to: CGSize(width: UIScreen.main.bounds.width - 40, height: 60))
createAccountButton.autoPinEdge(toSuperviewEdge: .left, withInset: 20)
createAccountButton.autoPinEdge(toSuperviewEdge: .right, withInset: 20)
loginButton.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 20)
loginButton.autoPinEdge(toSuperviewEdge: .left, withInset: 20)
loginButton.autoPinEdge(toSuperviewEdge: .right, withInset: 20)
loginButton.autoSetDimensions(to: CGSize(width: UIScreen.main.bounds.width - 40, height: 60))
view.bringSubviewToFront(titleCollectionView)
}
UICollectionView conforms to scrollView methods all our logic will be in the scrollViewDidScroll method.
To enable animating page indicators, when scrollViewDidScroll method is called, we calculate the offset that happened with this formula and pass it to the pageControlSTackView:
let x = scrollView.contentOffset.x
let width = scrollView.frame.width
let offset = x / width
pageControlStackView.setPageOffset(offset)
In the same scrollViewDidScroll method, we first want to check in which direction the scrollview scrolled so that we can appropriately animate images fading. We add a helper enum to achieve this:
enum Direction {
case left
case right
}
if scrollView.panGestureRecognizer.translation(in: scrollView.superview).x < 0 {
fadeImages(direction: .left, x: x, width: width)
} else {
fadeImages(direction: .right, x: x, width: width)
}
To fade images, we will change their alpha. Alpha is changed by calculating the offset and putting it in the space from 0.0 to 1.0. Depending on the image index, images will fade. Here is the formula we will use:
func fadeImages(direction: Direction, x: CGFloat, width: CGFloat) {
let index: Int = viewModel.getPageIndex()
let pagesCount = viewModel.getNumberOfPages()
switch direction {
case .left:
let fadeInAlpha = (x - (width * CGFloat(index))) / width
let fadeOutAlpha = 1 - fadeInAlpha
let imageToFadeInIndex = index % pagesCount + 1
if (index % pagesCount) % 2 == 0 && imageToFadeInIndex < pagesCount {
secondImageView.image = UIImage(named: viewModel.getImage(index: imageToFadeInIndex))
firstImageView.alpha = fadeOutAlpha
secondImageView.alpha = fadeInAlpha
} else if (index % pagesCount) % 2 == 1 && imageToFadeInIndex < pagesCount {
firstImageView.image = UIImage(named: viewModel.getImage(index: index % pagesCount + 1))
secondImageView.alpha = fadeOutAlpha
firstImageView.alpha = fadeInAlpha
}
case .right:
let fadeInAlpha = -1 * ((x - (width * CGFloat(index))) / width)
let fadeOutAlpha = 1 - fadeInAlpha
let imageToFadeInIndex = index % pagesCount - 1
if (index % pagesCount) % 2 == 1 && imageToFadeInIndex >= 0 {
firstImageView.image = UIImage(named: viewModel.getImage(index: imageToFadeInIndex))
firstImageView.alpha = fadeInAlpha
secondImageView.alpha = fadeOutAlpha
} else if (index % pagesCount) % 2 == 0 && imageToFadeInIndex >= 0 {
secondImageView.image = UIImage(named: viewModel.getImage(index: imageToFadeInIndex))
secondImageView.alpha = fadeInAlpha
firstImageView.alpha = fadeOutAlpha
}
}
}
Depending on the index we are at, we will alternate between the two UIImageViews we have created.
Also, you have to keep in mind to not go out of bound in the array.
By taking this custom approach, we have more control over the design and functionality of our onboarding swiper, allowing us to create a more engaging and effective user experience. Plus, by using MVVM architecture, we can ensure our code is modular, easy to maintain, and testable.
In this post, we've explored an alternative approach to creating an onboarding swiper in iOS using UICollectionView and MVVM architecture. By doing so, we've seen how we can create a smoother, more user-friendly onboarding experience that gives us greater control over the design and functionality of our swiper. With the tips and techniques outlined here, you'll be able to create your own custom onboarding swiper that engages your users and guides them through your app's key features and functionality.
In case you're curious, feel free to contact us. Our ASEE team will be happy to hear you out.
Author: Karolina Škunca
Karolina is a Junior iOS Software Developer. She works on preventing security attacks on iOS phones and frequently tests ASEE’s applications. Her greatest passions are designing and developing new applications.