Dactyloidae/mobile/ios/Client/Frontend/Intro/IntroViewController.swift

437 lines
22 KiB
Swift
Raw Normal View History

2026-06-26 21:04:09 -07:00
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import UIKit
import SnapKit
struct IntroViewControllerUX {
static let Width = 375
static let Height = 667
static let SyncButtonTopPadding = 5
static let MinimumFontScale: CGFloat = 0.5
static let CardSlides = ["tour-Welcome", "tour-Search", "tour-Private", "tour-Mail", "tour-Sync"]
static let NumberOfCards = CardSlides.count
static let PagerCenterOffsetFromScrollViewBottom = UIScreen.main.bounds.width <= 320 ? 20 : 30
static let StartBrowsingButtonTitle = NSLocalizedString("Start Browsing", tableName: "Intro", comment: "See http://mzl.la/1T8gxwo")
static let StartBrowsingButtonColor = UIColor(rgb: 0x4990E2)
static let StartBrowsingButtonHeight = 56
static let SignInButtonTitle = NSLocalizedString("Sign in to Firefox", tableName: "Intro", comment: "See http://mzl.la/1T8gxwo")
static let SignInButtonColor = UIColor(rgb: 0x45A1FF)
static let SignInButtonHeight = 60
static let SignInButtonWidth = 290
static let CardTextLineHeight = UIScreen.main.bounds.width <= 320 ? CGFloat(2) : CGFloat(6)
static let CardTextWidth = UIScreen.main.bounds.width <= 320 ? 240 : 280
static let CardTitleHeight = 50
static let CardTitleWelcome = NSLocalizedString("Intro.Slides.Welcome.Title", tableName: "Intro", value: "Thanks for choosing Firefox!", comment: "Title for the first panel 'Welcome' in the First Run tour.")
static let CardTitleSearch = NSLocalizedString("Intro.Slides.Search.Title", tableName: "Intro", value: "Your search, your way", comment: "Title for the second panel 'Search' in the First Run tour.")
static let CardTitlePrivate = NSLocalizedString("Intro.Slides.Private.Title", tableName: "Intro", value: "Browse like no ones watching", comment: "Title for the third panel 'Private Browsing' in the First Run tour.")
static let CardTitleMail = NSLocalizedString("Intro.Slides.Mail.Title", tableName: "Intro", value: "Youve got mail… options", comment: "Title for the fourth panel 'Mail' in the First Run tour.")
static let CardTitleSync = NSLocalizedString("Intro.Slides.Sync.Title", tableName: "Intro", value: "Pick up where you left off", comment: "Title for the fifth panel 'Sync' in the First Run tour.")
static let CardTextWelcome = NSLocalizedString("Intro.Slides.Welcome.Description", tableName: "Intro", value: "A modern mobile browser from Mozilla, the non-profit committed to a free and open web.", comment: "Description for the 'Welcome' panel in the First Run tour.")
static let CardTextSearch = NSLocalizedString("Intro.Slides.Search.Description", tableName: "Intro", value: "Searching for something different? Choose another default search engine (or add your own) in Settings.", comment: "Description for the 'Favorite Search Engine' panel in the First Run tour.")
static let CardTextPrivate = NSLocalizedString("Intro.Slides.Private.Description", tableName: "Intro", value: "Tap the mask icon to slip into Private Browsing mode.", comment: "Description for the 'Private Browsing' panel in the First Run tour.")
static let CardTextMail = NSLocalizedString("Intro.Slides.Mail.Description", tableName: "Intro", value: "Use any email app — not just Mail — with Firefox.", comment: "Description for the 'Mail' panel in the First Run tour.")
static let CardTextSync = NSLocalizedString("Intro.Slides.Sync.Description", tableName: "Intro", value: "Use Sync to find the bookmarks, passwords, and other things you save to Firefox on all your devices.", comment: "Description for the 'Sync' panel in the First Run tour.")
static let FadeDuration = 0.25
}
let IntroViewControllerSeenProfileKey = "IntroViewControllerSeen"
protocol IntroViewControllerDelegate: class {
func introViewControllerDidFinish(_ introViewController: IntroViewController, requestToLogin: Bool)
}
class IntroViewController: UIViewController, UIScrollViewDelegate {
weak var delegate: IntroViewControllerDelegate?
var slides = [UIImage]()
var cards = [UIImageView]()
var introViews = [UIView]()
var titleLabels = [UILabel]()
var textLabels = [UILabel]()
var startBrowsingButton: UIButton!
var introView: UIView?
var slideContainer: UIView!
var pageControl: UIPageControl!
var backButton: UIButton!
var forwardButton: UIButton!
var signInButton: UIButton!
fileprivate var scrollView: IntroOverlayScrollView!
var slideVerticalScaleFactor: CGFloat = 1.0
override func viewDidLoad() {
view.backgroundColor = UIColor.white
// scale the slides down for iPhone 4S
if view.frame.height <= 480 {
slideVerticalScaleFactor = 1.33
slideVerticalScaleFactor = 1.33 //4S
} else if view.frame.height <= 568 {
slideVerticalScaleFactor = 1.15 //SE
}
for slideName in IntroViewControllerUX.CardSlides {
slides.append(UIImage(named: slideName)!)
}
startBrowsingButton = UIButton()
startBrowsingButton.backgroundColor = UIColor.clear
startBrowsingButton.setTitle(IntroViewControllerUX.StartBrowsingButtonTitle, for: UIControlState())
startBrowsingButton.setTitleColor(IntroViewControllerUX.StartBrowsingButtonColor, for: UIControlState())
startBrowsingButton.addTarget(self, action: #selector(IntroViewController.SELstartBrowsing), for: UIControlEvents.touchUpInside)
startBrowsingButton.accessibilityIdentifier = "IntroViewController.startBrowsingButton"
view.addSubview(startBrowsingButton)
startBrowsingButton.snp.makeConstraints { (make) -> Void in
if #available(iOS 11.0, *) {
make.left.right.bottom.equalTo(self.view.safeAreaLayoutGuide)
} else {
make.left.right.bottom.equalTo(self.view)
}
make.height.equalTo(IntroViewControllerUX.StartBrowsingButtonHeight)
}
scrollView = IntroOverlayScrollView()
scrollView.backgroundColor = UIColor.clear
scrollView.accessibilityLabel = NSLocalizedString("Intro Tour Carousel", comment: "Accessibility label for the introduction tour carousel")
scrollView.delegate = self
scrollView.bounces = false
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.contentSize = CGSize(width: scaledWidthOfSlide * CGFloat(IntroViewControllerUX.NumberOfCards), height: scaledHeightOfSlide)
scrollView.accessibilityIdentifier = "IntroViewController.scrollView"
view.addSubview(scrollView)
slideContainer = UIView()
for i in 0..<IntroViewControllerUX.NumberOfCards {
let imageView = UIImageView(frame: CGRect(x: CGFloat(i)*scaledWidthOfSlide, y: 0, width: scaledWidthOfSlide, height: scaledHeightOfSlide))
imageView.image = slides[i]
slideContainer.addSubview(imageView)
}
scrollView.addSubview(slideContainer)
scrollView.snp.makeConstraints { (make) -> Void in
make.left.right.top.equalTo(self.view)
make.bottom.equalTo(startBrowsingButton.snp.top)
}
self.view.layoutIfNeeded()
self.scrollView.layoutIfNeeded()
pageControl = UIPageControl()
pageControl.pageIndicatorTintColor = UIColor.black.withAlphaComponent(0.3)
pageControl.currentPageIndicatorTintColor = UIColor.black
pageControl.numberOfPages = IntroViewControllerUX.NumberOfCards
pageControl.accessibilityIdentifier = "IntroViewController.pageControl"
pageControl.addTarget(self, action: #selector(IntroViewController.changePage), for: UIControlEvents.valueChanged)
view.addSubview(pageControl)
pageControl.snp.makeConstraints { (make) -> Void in
make.centerX.equalTo(self.scrollView)
make.centerY.equalTo(self.startBrowsingButton.snp.top).offset(-IntroViewControllerUX.PagerCenterOffsetFromScrollViewBottom)
}
func addCard(title: String, text: String, introView: UIView) {
self.introViews.append(introView)
let titleLabel = UILabel()
titleLabel.numberOfLines = 2
titleLabel.adjustsFontSizeToFitWidth = true
titleLabel.minimumScaleFactor = IntroViewControllerUX.MinimumFontScale
titleLabel.textAlignment = NSTextAlignment.center
titleLabel.text = title
titleLabels.append(titleLabel)
introView.addSubview(titleLabel)
titleLabel.snp.makeConstraints { (make ) -> Void in
make.top.equalTo(introView).offset(20)
make.centerX.equalTo(introView)
make.height.equalTo(IntroViewControllerUX.CardTitleHeight)
make.width.equalTo(IntroViewControllerUX.CardTextWidth)
}
let textLabel = UILabel()
textLabel.numberOfLines = 5
textLabel.attributedText = attributedStringForLabel(text)
textLabel.adjustsFontSizeToFitWidth = true
textLabel.minimumScaleFactor = IntroViewControllerUX.MinimumFontScale
textLabel.lineBreakMode = .byTruncatingTail
textLabels.append(textLabel)
introView.addSubview(textLabel)
textLabel.snp.makeConstraints({ (make) -> Void in
make.top.equalTo(titleLabel.snp.bottom).offset(20)
make.centerX.equalTo(introView)
make.width.equalTo(IntroViewControllerUX.CardTextWidth)
})
}
addCard(title: IntroViewControllerUX.CardTitleWelcome, text: IntroViewControllerUX.CardTextWelcome, introView: UIView())
addCard(title: IntroViewControllerUX.CardTitleSearch, text: IntroViewControllerUX.CardTextSearch, introView: UIView())
addCard(title: IntroViewControllerUX.CardTitlePrivate, text: IntroViewControllerUX.CardTextPrivate, introView: UIView())
addCard(title: IntroViewControllerUX.CardTitleMail, text: IntroViewControllerUX.CardTextMail, introView: UIView())
// Sync card, with sign in to sync button.
signInButton = UIButton()
signInButton.backgroundColor = IntroViewControllerUX.SignInButtonColor
signInButton.setTitle(IntroViewControllerUX.SignInButtonTitle, for: UIControlState())
signInButton.setTitleColor(UIColor.white, for: UIControlState())
signInButton.clipsToBounds = true
signInButton.addTarget(self, action: #selector(IntroViewController.SELlogin), for: UIControlEvents.touchUpInside)
signInButton.snp.makeConstraints { (make) -> Void in
make.height.equalTo(IntroViewControllerUX.SignInButtonHeight)
}
let syncCardView = UIView()
// introViews.append(syncCardView)
addCard(title: IntroViewControllerUX.CardTitleSync, text: IntroViewControllerUX.CardTextSync, introView: syncCardView)
syncCardView.addSubview(signInButton)
syncCardView.bringSubview(toFront: signInButton)
signInButton.snp.makeConstraints { (make) -> Void in
make.centerX.equalTo(syncCardView)
make.width.equalTo(IntroViewControllerUX.CardTextWidth) // TODO Talk to UX about small screen sizes
make.bottom.equalTo(syncCardView)
}
// We need a reference to the sync pages textlabel so we can adjust the constraints
if let index = introViews.index(of: syncCardView), index < textLabels.count {
let syncTextLabel = textLabels[index]
syncTextLabel.snp.makeConstraints { make in
make.bottom.equalTo(signInButton.snp.top).offset(-IntroViewControllerUX.SyncButtonTopPadding)
}
}
// Add all the cards to the view, make them invisible with zero alpha
for introView in introViews {
introView.alpha = 0
self.view.addSubview(introView)
introView.snp.makeConstraints { (make) -> Void in
make.top.equalTo(self.slideContainer.snp.bottom)
make.bottom.equalTo(self.startBrowsingButton.snp.top)
make.left.right.equalTo(self.view)
}
}
// Make whole screen scrollable by bringing the scrollview to the top
view.bringSubview(toFront: scrollView)
view.sendSubview(toBack: pageControl)
scrollView?.bringSubview(toFront: syncCardView)
signInButton.superview?.bringSubview(toFront: signInButton)
// Activate the first card
setActiveIntroView(introViews[0], forPage: 0)
setupDynamicFonts()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NotificationCenter.default.addObserver(self, selector: #selector(SELDynamicFontChanged(_:)), name: NotificationDynamicFontChanged, object: nil)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
NotificationCenter.default.removeObserver(self, name: NotificationDynamicFontChanged, object: nil)
}
func SELDynamicFontChanged(_ notification: Notification) {
guard notification.name == NotificationDynamicFontChanged else { return }
setupDynamicFonts()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.snp.remakeConstraints { (make) -> Void in
make.left.right.top.equalTo(self.view)
make.bottom.equalTo(self.startBrowsingButton.snp.top)
}
for i in 0..<IntroViewControllerUX.NumberOfCards {
if let imageView = slideContainer.subviews[i] as? UIImageView {
imageView.frame = CGRect(x: CGFloat(i)*scaledWidthOfSlide, y: 0, width: scaledWidthOfSlide, height: scaledHeightOfSlide)
imageView.contentMode = UIViewContentMode.scaleAspectFit
}
}
slideContainer.frame = CGRect(x: 0, y: 0, width: scaledWidthOfSlide * CGFloat(IntroViewControllerUX.NumberOfCards), height: scaledHeightOfSlide)
scrollView.contentSize = CGSize(width: slideContainer.frame.width, height: slideContainer.frame.height)
}
override var prefersStatusBarHidden: Bool {
return true
}
override var shouldAutorotate: Bool {
return false
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
// This actually does the right thing on iPad where the modally
// presented version happily rotates with the iPad orientation.
return UIInterfaceOrientationMask.portrait
}
func SELstartBrowsing() {
LeanPlumClient.shared.track(event: .dismissedOnboarding)
delegate?.introViewControllerDidFinish(self, requestToLogin: false)
}
func SELback() {
if introView == introViews[1] {
setActiveIntroView(introViews[0], forPage: 0)
scrollView.scrollRectToVisible(scrollView.subviews[0].frame, animated: true)
pageControl.currentPage = 0
} else if introView == introViews[2] {
setActiveIntroView(introViews[1], forPage: 1)
scrollView.scrollRectToVisible(scrollView.subviews[1].frame, animated: true)
pageControl.currentPage = 1
}
}
func SELforward() {
if introView == introViews[0] {
setActiveIntroView(introViews[1], forPage: 1)
scrollView.scrollRectToVisible(scrollView.subviews[1].frame, animated: true)
pageControl.currentPage = 1
} else if introView == introViews[1] {
setActiveIntroView(introViews[2], forPage: 2)
scrollView.scrollRectToVisible(scrollView.subviews[2].frame, animated: true)
pageControl.currentPage = 2
}
}
func SELlogin() {
delegate?.introViewControllerDidFinish(self, requestToLogin: true)
}
fileprivate var accessibilityScrollStatus: String {
let number = NSNumber(value: pageControl.currentPage + 1)
return String(format: NSLocalizedString("Introductory slide %@ of %@", tableName: "Intro", comment: "String spoken by assistive technology (like VoiceOver) stating on which page of the intro wizard we currently are. E.g. Introductory slide 1 of 3"), NumberFormatter.localizedString(from: number, number: .decimal), NumberFormatter.localizedString(from: NSNumber(value: IntroViewControllerUX.NumberOfCards), number: .decimal))
}
func changePage() {
let swipeCoordinate = CGFloat(pageControl.currentPage) * scrollView.frame.size.width
scrollView.setContentOffset(CGPoint(x: swipeCoordinate, y: 0), animated: true)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
// Need to add this method so that when forcibly dragging, instead of letting deceleration happen, should also calculate what card it's on.
// This especially affects sliding to the last or first slides.
if !decelerate {
scrollViewDidEndDecelerating(scrollView)
}
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
// Need to add this method so that tapping the pageControl will also change the card texts.
// scrollViewDidEndDecelerating waits until the end of the animation to calculate what card it's on.
scrollViewDidEndDecelerating(scrollView)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let page = Int(scrollView.contentOffset.x / scrollView.frame.size.width)
setActiveIntroView(introViews[page], forPage: page)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let maximumHorizontalOffset = scrollView.frame.width
let currentHorizontalOffset = scrollView.contentOffset.x
var percentageOfScroll = currentHorizontalOffset / maximumHorizontalOffset
percentageOfScroll = percentageOfScroll > 1.0 ? 1.0 : percentageOfScroll
let whiteComponent = UIColor.white.components
let grayComponent = UIColor(rgb: 0xF2F2F2).components
let page = Int(scrollView.contentOffset.x / scrollView.frame.size.width)
pageControl.currentPage = page
let newRed = (1.0 - percentageOfScroll) * whiteComponent.red + percentageOfScroll * grayComponent.red
let newGreen = (1.0 - percentageOfScroll) * whiteComponent.green + percentageOfScroll * grayComponent.green
let newBlue = (1.0 - percentageOfScroll) * whiteComponent.blue + percentageOfScroll * grayComponent.blue
let newColor = UIColor(red: newRed, green: newGreen, blue: newBlue, alpha: 1.0)
slideContainer.backgroundColor = newColor
}
fileprivate func setActiveIntroView(_ newIntroView: UIView, forPage page: Int) {
if introView != newIntroView {
UIView.animate(withDuration: IntroViewControllerUX.FadeDuration, animations: { () -> Void in
self.introView?.alpha = 0
self.introView = newIntroView
newIntroView.alpha = 1.0
if page == 0 {
self.startBrowsingButton.alpha = 0
} else {
self.startBrowsingButton.alpha = 1
}
}, completion: { _ in
if page == (IntroViewControllerUX.NumberOfCards - 1) {
self.scrollView.signinButton = self.signInButton
} else {
self.scrollView.signinButton = nil
}
})
}
}
fileprivate var scaledWidthOfSlide: CGFloat {
return view.frame.width
}
fileprivate var scaledHeightOfSlide: CGFloat {
return (view.frame.width / slides[0].size.width) * slides[0].size.height / slideVerticalScaleFactor
}
fileprivate func attributedStringForLabel(_ text: String) -> NSMutableAttributedString {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = IntroViewControllerUX.CardTextLineHeight
paragraphStyle.alignment = .center
let string = NSMutableAttributedString(string: text)
string.addAttribute(NSParagraphStyleAttributeName, value: paragraphStyle, range: NSRange(location: 0, length: string.length))
return string
}
fileprivate func setupDynamicFonts() {
startBrowsingButton.titleLabel?.font = UIFont(name: "FiraSans-Regular", size: DynamicFontHelper.defaultHelper.IntroStandardFontSize)
signInButton.titleLabel?.font = UIFont(name: "FiraSans-Regular", size: DynamicFontHelper.defaultHelper.IntroBigFontSize)
for titleLabel in titleLabels {
titleLabel.font = UIFont(name: "FiraSans-Medium", size: DynamicFontHelper.defaultHelper.IntroBigFontSize)
}
for label in textLabels {
label.font = UIFont(name: "FiraSans-UltraLight", size: DynamicFontHelper.defaultHelper.IntroStandardFontSize)
}
}
}
fileprivate class IntroOverlayScrollView: UIScrollView {
weak var signinButton: UIButton?
fileprivate override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
if let signinFrame = signinButton?.frame {
let convertedFrame = convert(signinFrame, from: signinButton?.superview)
if convertedFrame.contains(point) {
return false
}
}
return CGRect(origin: self.frame.origin, size: CGSize(width: self.contentSize.width, height: self.frame.size.height)).contains(point)
}
}
extension UIColor {
var components:(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
}
}