Files
lockdown-iOS-mirror/LockdowniOS/OnboardingViewController.swift
Alexander Parshakov 2bc6adf847 Release 1.6.1
2023-01-10 21:17:38 +05:00

454 lines
16 KiB
Swift

//
// OnboardingViewController.swift
// Lockdown
//
// Created by Alexander Parshakov on 9/26/22
// Copyright © 2022 Confirmed Inc. All rights reserved.
//
import AVFoundation
import Foundation
import UIKit
final class OnboardingViewController: UIViewController, CAAnimationDelegate {
@IBOutlet private var progressBarStackView: UIStackView!
@IBOutlet private var infoLabelStackView: UIStackView!
@IBOutlet private var titleLabel: UILabel!
@IBOutlet private var descriptionLabel: UILabel!
@IBOutlet private var videoContentBorderView: UIView!
@IBOutlet private var videoContentView: UIView!
@IBOutlet private var imageView: UIImageView!
@IBOutlet private var leftTapGestureView: UIView!
@IBOutlet private var rightTapGestureView: UIView!
@IBOutlet private var signUpButton: UIButton!
@IBOutlet private var loginButton: UIButton!
@IBOutlet private var videoContentVerticalCenterConstraint: NSLayoutConstraint!
@IBOutlet private var imageContentLeadingConstraint: NSLayoutConstraint!
@IBOutlet private var imageContentTrailingConstraint: NSLayoutConstraint!
private var progressViews: [WeakObject<ProgressView>] = []
private var gradientLayer: CAGradientLayer?
private var avPlayerLayer: AVPlayerLayer?
private var avPlayer: AVPlayer?
/// Defining if page progress should be continued upon viewWillAppear.
private var hasDisappearedBefore = false
private let configuration = OnboardingConfiguration()
private var progressBarTimer: Timer?
private var currentTimerDuration: Float = 0
private var currentProgressPercentage: Float {
switch currentPage.content {
case .image(_, let duration, _):
return currentTimerDuration / duration
case .video:
return currentTimerDuration / currentPage.videoDuration
}
}
private let pages: [OnboardingPage]
private var currentPage: OnboardingPage { pages[currentPageIndex] }
@PositiveAndArrayRestricted(defaultValue: 0)
private var currentPageIndex {
didSet {
DispatchQueue.main.async {
self.updatePage()
}
}
}
init(pages: [OnboardingPage] = .defaultPages) {
self.pages = pages
_currentPageIndex.arrayCount = pages.count
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupProgressViews()
updatePage()
setupTexts()
setupButtons()
setupSideTapGestures()
configureContentSizingForIdiom()
NotificationCenter.default.addObserver(self,
selector: #selector(freezePageProgress),
name: UIApplication.didEnterBackgroundNotification,
object: nil)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
view.layoutIfNeeded()
gradientLayer?.frame = view.bounds
avPlayerLayer?.frame = videoContentView.bounds
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.navigationBar.tintColor = .label
navigationController?.navigationBar.barStyle = .default
navigationController?.setNavigationBarHidden(true, animated: animated)
if hasDisappearedBefore {
continuePageProgress()
hasDisappearedBefore = false
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.setNavigationBarHidden(false, animated: animated)
freezePageProgress()
hasDisappearedBefore = true
}
@IBAction private func didTapSignUp(_ sender: Any) {
OneTimeActions.markAsSeen(.newFancyOnboarding)
signUpButton.showAnimatedPress { [weak self] in
let signUpViewController = SignUpViewController(mode: .signUp)
self?.navigationController?.pushViewController(signUpViewController, animated: true)
}
}
@IBAction private func didTapLogin(_ sender: Any) {
OneTimeActions.markAsSeen(.newFancyOnboarding)
loginButton.showAnimatedPress { [weak self] in
let signUpViewController = SignUpViewController(mode: .login)
self?.navigationController?.pushViewController(signUpViewController, animated: true)
}
}
// MARK: - Private Methods
private func setupProgressViews() {
progressBarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
progressBarStackView.layoutIfNeeded()
for _ in pages {
let progressView = ProgressView()
progressBarStackView.addArrangedSubview(progressView)
progressViews.append(WeakObject(progressView))
progressView.heightAnchor.constraint(equalToConstant: isPad ? 6 : 4).isActive = true
}
}
private func setupTexts() {
signUpButton.setTitle(.localized("onboarding_sign_up"), for: .normal)
loginButton.setTitle(.localized("onboarding_login"), for: .normal)
// https://stackoverflow.com/questions/46200027/uilabel-wrong-word-wrap-in-ios-11/
// To prevent UIKit from moving the penultimate word to the line with the last orphaned word.
titleLabel.lineBreakStrategy = []
}
private func setupButtons() {
[signUpButton, loginButton].forEach {
$0?.dropShadow()
$0?.corners = .continuous(16)
$0?.titleLabel?.adjustsFontSizeToFitWidth = true
$0?.contentEdgeInsets = .init(top: 0, left: 12, bottom: 0, right: 12)
$0?.clipsToBounds = true
}
}
private func configureContentSizingForIdiom() {
if UIDevice.current.userInterfaceIdiom == .pad {
// Make video centered on iPad
videoContentVerticalCenterConstraint.priority = .required
// Lower priority of side constraints, so the =< width constraint in XIB is prioritized.
// This gives the imageView on iPad [limited] space to grow.
// As a result, the image doesn't look overly big on large iPads.
imageContentLeadingConstraint.priority = .defaultHigh
imageContentTrailingConstraint.priority = .defaultHigh
} else {
videoContentVerticalCenterConstraint.priority = .defaultHigh
imageContentLeadingConstraint.priority = .required
imageContentTrailingConstraint.priority = .required
}
}
private func updatePage() {
UIView.transition(with: infoLabelStackView,
duration: configuration.contentAnimationDuration,
options: .transitionCrossDissolve) { [weak self] in
guard let self = self else { return }
self.titleLabel.text = self.currentPage.title
self.descriptionLabel.text = self.currentPage.description
}
updateGradient()
updateContent()
}
private func updateContent() {
imageView.prepareForReuse()
switch currentPage.content {
case .image(let title, _, let presentationType):
switchContentVisibility(isVideoContent: false)
showImage(title: title, animation: presentationType.animation)
case .video(let videoTitle):
switchContentVisibility(isVideoContent: true)
showVideo(title: videoTitle)
}
view.setNeedsLayout()
startTimer()
}
private func switchContentVisibility(isVideoContent: Bool) {
imageView.isHidden = isVideoContent
videoContentView.isHidden = !isVideoContent
videoContentBorderView.isHidden = !isVideoContent
}
private func showImage(title: String, animation: ImageViewAnimationCompletion) {
imageView.image = UIImage(named: title) ?? UIImage()
imageView.prepareForReuse()
DispatchQueue.main.async {
animation?(self.imageView)
}
}
private func showVideo(title: String) {
guard let videoPath = Bundle.main.path(forResource: title, ofType: "mp4") else { return }
avPlayerLayer?.removeFromSuperlayer()
avPlayerLayer = nil
videoContentView.corners = .continuous(12)
videoContentBorderView.corners = .continuous(12)
let videoUrl = URL(fileURLWithPath: videoPath)
let avPlayer = AVPlayer(url: videoUrl)
let avPlayerLayer = AVPlayerLayer(player: avPlayer)
avPlayerLayer.videoGravity = .resizeAspectFill
avPlayerLayer.corners = .continuous(12)
self.avPlayerLayer = avPlayerLayer
self.avPlayer = avPlayer
videoContentView.layer.addSublayer(avPlayerLayer)
avPlayer.play()
}
@objc private func updateProgressView() {
let currentProgressView = progressViews[currentPageIndex]
currentTimerDuration += Float(configuration.timerInterval)
currentProgressView.object?.progress = currentProgressPercentage
if currentProgressPercentage >= 1.0 {
stopTimer()
switchToNextPage()
}
}
private func startTimer() {
progressBarTimer = Timer.scheduledTimer(timeInterval: configuration.timerInterval,
target: self,
selector: #selector(updateProgressView),
userInfo: nil,
repeats: true)
}
private func stopTimer(keepProgress: Bool = false) {
if !keepProgress {
currentTimerDuration = 0
}
progressBarTimer?.invalidate()
progressBarTimer = nil
}
@objc private func continuePageProgress() {
guard progressBarTimer == nil else { return }
// Removing observer of returning to app since it makes no sense when we are in the app.
NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil)
DispatchQueue.main.async {
if case .image = self.currentPage.content {
// If it's an image, we have a complete restart of content displaying.
self.updateContent()
} else {
// If it is a video, we continue playing it from where we stopped
// and restart the timer for progress view updating.
self.avPlayer?.play()
self.startTimer()
}
}
}
@objc private func freezePageProgress() {
// Adding observer only here so continuePageProgress isn't called additionally in the beginning.
// This observer will be added upon leaving the app and removed upon returning.
NotificationCenter.default.addObserver(self,
selector: #selector(continuePageProgress),
name: UIApplication.didBecomeActiveNotification,
object: nil)
// Freezing progress depending on the type of content.
DispatchQueue.main.async {
if case .image = self.currentPage.content {
// If it is an image, we will have to completely restart animation upon returning.
// For smoothness, we hide the image before leaving the app.
self.stopTimer()
self.imageView.isHidden = true
} else {
// If it is a video, we will just continue playing it upon returning, hence we pause and keep progress.
self.stopTimer(keepProgress: true)
self.avPlayer?.pause()
}
}
}
}
// MARK: - Gesture Recognizers
extension OnboardingViewController {
private func setupSideTapGestures() {
let leftTapGesture = UITapGestureRecognizer(target: self, action: #selector(switchToPreviousPage))
leftTapGestureView.addGestureRecognizer(leftTapGesture)
let rightTapGesture = UITapGestureRecognizer(target: self, action: #selector(switchToNextPage))
rightTapGestureView.addGestureRecognizer(rightTapGesture)
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
longPressGesture.minimumPressDuration = 0.3
view.addGestureRecognizer(longPressGesture)
view.bringSubviewToFront(leftTapGestureView)
view.bringSubviewToFront(rightTapGestureView)
}
@objc private func switchToPreviousPage() {
progressViews.resetProgress(at: currentPageIndex)
currentPageIndex -= 1
progressViews.resetProgress(at: currentPageIndex)
stopTimer()
}
@objc private func switchToNextPage() {
currentTimerDuration = 0
progressBarTimer?.invalidate()
if currentPageIndex == pages.count - 1 {
imageView.layer.removeAllAnimations()
progressViews.resetProgresses()
currentPageIndex = 0
} else {
UIView.performWithoutAnimation {
self.progressViews[currentPageIndex].object?.progress = 1
self.progressViews[currentPageIndex].object?.layoutIfNeeded()
}
currentPageIndex += 1
}
}
@objc private func handleLongPress(gestureReconizer: UILongPressGestureRecognizer) {
if gestureReconizer.state == .ended {
resumeContentAfterLongPress()
fadeSecondaryElements(visible: true)
} else if gestureReconizer.state != .changed {
pauseContentOnLongPress()
fadeSecondaryElements(visible: false)
}
}
private func pauseContentOnLongPress() {
DispatchQueue.main.async {
self.stopTimer(keepProgress: true)
if case .image = self.currentPage.content {
self.imageView.layer.pause()
} else {
self.avPlayer?.pause()
}
}
}
private func resumeContentAfterLongPress() {
DispatchQueue.main.async {
self.startTimer()
if case .image = self.currentPage.content {
self.imageView.layer.resume()
} else {
self.avPlayer?.play()
}
}
}
private func fadeSecondaryElements(visible: Bool) {
UIView.animate(withDuration: 0.2) {
[self.progressBarStackView,
self.signUpButton,
self.loginButton
].forEach {
$0?.alpha = visible ? 1 : 0
}
}
}
}
// MARK: - Gradient Update
extension OnboardingViewController {
private func updateGradient() {
if gradientLayer == nil {
gradientLayer = view.applyGradient(currentPage.gradient)
}
animateGradientToNextColor()
}
private func animateGradientToNextColor() {
let fromColors = gradientLayer?.colors
let toColors: [CGColor] = currentPage.gradient.colors
gradientLayer?.colors = currentPage.gradient.colors
let animation: CABasicAnimation = CABasicAnimation(keyPath: "colors")
animation.fromValue = fromColors
animation.toValue = toColors
animation.duration = configuration.contentAnimationDuration
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
gradientLayer?.add(animation, forKey:"animateGradient")
}
}
private extension Array where Element == WeakObject<ProgressView> {
func resetProgresses() {
forEach { $0.object?.progress = 0 }
}
func resetProgress(at index: Int) {
let progressBar = self[index]
UIView.performWithoutAnimation {
progressBar.object?.progress = 0
progressBar.object?.layoutIfNeeded()
}
}
}