Skip to content

Commit

Permalink
PM-16154: Learn new login guided tour (#1266)
Browse files Browse the repository at this point in the history
  • Loading branch information
ezimet-livefront authored Jan 22, 2025
1 parent 619e70e commit 632b617
Show file tree
Hide file tree
Showing 35 changed files with 1,169 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// A extension to `CGRect` that allows enlarging the rect by a given value.
extension CGRect {
/// Returns a new `CGRect` that is enlarged by the given value, will add the value to each side of the rect.
///
/// - Parameter value: The value to enlarge the `CGRect` by.
/// - Returns: A new `CGRect` that is enlarged by the given value.
///
func enlarged(by value: CGFloat) -> CGRect {
CGRect(
x: origin.x - value,
y: origin.y - value,
width: size.width + 2 * value,
height: size.height + 2 * value
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import SwiftUI

extension View {
/// A view modifier that informs the guided tour the spotlight region of the
/// view and assigns an identifier to the view.
///
/// - Parameters:
/// - step: The guided tour step.
/// - perform: A closure called when the size or origin of the view changes.
/// - Returns: A copy of the view with the guided tour step modifier applied.
///
func guidedTourStep(_ step: GuidedTourStep, perform: @escaping (CGRect) -> Void) -> some View {
onFrameChanged { origin, size in
perform(CGRect(origin: origin, size: size))
}
.id(step)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@ extension View {
)
.onPreferenceChange(ViewSizeKey.self, perform: perform)
}

/// A view modifier that calculates the origin and size of the containing view.
///
/// - Parameter perform: A closure called when the size or origin of the view changes.
/// - Returns: A copy of the view with the sizing and origin modifier applied.
///
func onFrameChanged(perform: @escaping (CGPoint, CGSize) -> Void) -> some View {
background(
GeometryReader { geometry in
Color.clear
.preference(
key: ViewFrameKey.self,
value: CGRect(
origin: geometry.frame(in: .global).origin,
size: geometry.size
)
)
}
)
.onPreferenceChange(ViewFrameKey.self) { value in
perform(value.origin, value.size)
}
}
}

/// A `PreferenceKey` used to calculate the size of a view.
Expand All @@ -26,3 +49,15 @@ private struct ViewSizeKey: PreferenceKey {
value = nextValue()
}
}

/// A `PreferenceKey` used to calculate the size and origin of a view.
///
private struct ViewFrameKey: PreferenceKey {
static var defaultValue = CGRect.zero

static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
if nextValue() != defaultValue {
value = nextValue()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"images" : [
{
"filename" : "arrow-down.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "arrow-darkmode-down.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "arrow-up.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "arrow-darkmode-up.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
"Tools" = "Tools";
"URI" = "URI";
"UseFingerprintToUnlock" = "Use fingerprint to unlock";
"UseThisButtonToGenerateANewUniquePassword" = "Use this button to generate a new unique password.";
"YouWillOnlyNeedToSetUpAnAuthenticatorKeyDescriptionLong" = "You'll only need to set up Authenticator Key for logins that require two-factor authentication with a code. The key will continuously generate six-digit codes you can use to log in.";
"YouMustAddAWebAddressToUseAutofillToAccessThisAccount" = "You must add a web address to use autofill to access this account.";
"Username" = "Username";
"ValidationFieldRequired" = "The %1$@ field is required.";
"ValueHasBeenCopied" = "%1$@ copied";
Expand All @@ -79,6 +82,7 @@
"Website" = "Website";
"Yes" = "Yes";
"Account" = "Account";
"StepOfStep" = "%1$d OF %2$d";
"AccountCreated" = "Your new account has been created! You may now log in.";
"AddAnItem" = "Add an Item";
"AppExtension" = "App extension";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import SwiftUI

/// A view that removes the background of a full-screen cover.
/// This view is being used when we present guided tour view where we need to present a full-screen cover
/// and remove the default background provided by SwiftUI.
///
struct FullScreenCoverBackgroundRemovalView: UIViewRepresentable {
private class BackgroundRemovalView: UIView {
override func didMoveToWindow() {
super.didMoveToWindow()

superview?.superview?.backgroundColor = .clear
}
}

func makeUIView(context: Context) -> UIView {
BackgroundRemovalView()
}

func updateUIView(_ uiView: UIView, context: Context) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import SwiftUI

/// A scroll view that contains the guided tour content.
struct GuidedTourScrollView<Content: View>: View {
/// The store for the guided tour view.
@ObservedObject var store: Store<GuidedTourViewState, GuidedTourViewAction, Void>

/// The content of the scroll view.
@ViewBuilder var content: Content

/// An environment variable for getting the vertical size class of the view.
@Environment(\.verticalSizeClass) var verticalSizeClass

/// The ID for scrolling to top of the view.
let top = "top"

var body: some View {
ScrollViewReader { reader in
ScrollView {
// Dummy spacer view for scroll view to locate when scrolling to top
Spacer()
.frame(height: 0)
.id(top)

content
}
.fullScreenCover(isPresented: store.binding(get: { state in
state.showGuidedTour
}, send: { state in
.toggleGuidedTourVisibilityChanged(state)
})) {
guidedTourView()
}
.transaction { transaction in
// disable the default FullScreenCover modal animation
transaction.disablesAnimations = true
}
.onChange(of: verticalSizeClass) { _ in
handleLandscapeScroll(reader)
}
.onChange(of: store.state.currentIndex) { _ in
handleLandscapeScroll(reader)
}
.onChange(of: store.state.showGuidedTour) { newValue in
if newValue == false {
reader.scrollTo(top)
}
}
}
}

/// A view that presents the guided tour.
@ViewBuilder
private func guidedTourView() -> some View {
GuidedTourView(
store: store
)
}

/// Scrolls to the guided tour step when in landscape mode.
private func handleLandscapeScroll(_ reader: ScrollViewProxy) {
reader.scrollTo(GuidedTourStep(rawValue: store.state.currentIndex))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Foundation

/// Encapsulates the state of a guided tour step.
///
struct GuidedTourStepState: Equatable {
/// The horizontal position of the arrow.
var arrowHorizontalPosition: ArrowHorizontalPosition

/// The padding of the card from the leading edge.
var cardLeadingPadding: CGFloat = 24

/// The padding of the card from the trailing edge.
var cardTrailingPadding: CGFloat = 24

/// The region of the view to spotlight.
var spotlightRegion: CGRect = .zero

/// The shape of the spotlight.
var spotlightShape: SpotlightShape

/// The title of the guided tour card.
var title: String
}

/// The shape of the spotlight.
///
enum SpotlightShape: Equatable {
/// The spotlight is a circle.
case circle

/// The spotlight is a rectangle with rounded corners.
case rectangle(cornerRadius: CGFloat)
}

/// The horizontal position of the arrow.
enum ArrowHorizontalPosition {
/// The arrow is horizontally positioned at the left side of the spotlight.
/// The position is calculated by dividing the width of the spotlight by 3
/// and placing the arrow at the center of the first part.
case left

/// The arrow is horizontally positioned at the center of spotlight.
case center

/// The arrow is horizontally positioned at the left side of the spotlight.
/// The position is calculated by dividing the width of the spotlight by 3
/// and placing the arrow at the center of the last part.
case right
}

/// The vertical position of the coach mark.
enum CoachMarkVerticalPosition {
/// The coach mark is positioned at the top of spotlight.
case top

/// The coach mark is positioned at the bottom of spotlight.
case bottom
}

/// Common steps used by different guided tours.
///
enum GuidedTourStep: Int, Equatable {
/// The first step of the guided tour.
case step1

/// The second step of the guided tour.
case step2

/// The third step of the guided tour.
case step3

/// The fourth step of the guided tour.
case step4

/// The fifth step of the guided tour.
case step5

/// The sixth step of the guided tour.
case step6
}
Loading

0 comments on commit 632b617

Please sign in to comment.