-
Notifications
You must be signed in to change notification settings - Fork 32
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Task/Issue URL: https://app.asana.com/0/1204186595873227/1208077416568671/f iOS PR: duckduckgo/iOS#3282 macOS PR: duckduckgo/macos-browser#3149 What kind of version bump will this require?: Minor **Optional**: Tech Design URL: https://app.asana.com/0/481882893211075/1208094950223874/f **Description**: Extracts code from the onboarding that can be used by macOS as well
- Loading branch information
1 parent
db0a7b4
commit 48acec0
Showing
29 changed files
with
2,101 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
315 changes: 315 additions & 0 deletions
315
Sources/Onboarding/ContextualDaxDialogs/ContextualDaxDialogContent.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,315 @@ | ||
// | ||
// ContextualDaxDialogContent.swift | ||
// | ||
// Copyright © 2024 DuckDuckGo. All rights reserved. | ||
// | ||
// Licensed under the Apache License, Version 2.0 (the "License"); | ||
// you may not use this file except in compliance with the License. | ||
// You may obtain a copy of the License at | ||
// | ||
// http://www.apache.org/licenses/LICENSE-2.0 | ||
// | ||
// Unless required by applicable law or agreed to in writing, software | ||
// distributed under the License is distributed on an "AS IS" BASIS, | ||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
// See the License for the specific language governing permissions and | ||
// limitations under the License. | ||
// | ||
|
||
import Foundation | ||
import SwiftUI | ||
import Combine | ||
import Common | ||
|
||
#if canImport(UIKit) | ||
typealias PlatformFont = UIFont | ||
#else | ||
typealias PlatformFont = NSFont | ||
#endif | ||
|
||
public struct ContextualDaxDialogContent: View { | ||
|
||
public enum Orientation: Equatable { | ||
case verticalStack | ||
case horizontalStack(alignment: VerticalAlignment) | ||
} | ||
|
||
let title: String? | ||
let titleFont: Font? | ||
let messageFont: Font? | ||
public let message: NSAttributedString | ||
let list: [ContextualOnboardingListItem] | ||
let listAction: ((_ item: ContextualOnboardingListItem) -> Void)? | ||
let imageName: String? | ||
let customActionView: AnyView? | ||
let orientation: Orientation | ||
|
||
private let itemsToAnimate: [DisplayableTypes] | ||
|
||
public init( | ||
orientation: Orientation = .verticalStack, | ||
title: String? = nil, | ||
titleFont: Font? = nil, | ||
message: NSAttributedString, | ||
messageFont: Font? = nil, | ||
list: [ContextualOnboardingListItem] = [], | ||
listAction: ((_: ContextualOnboardingListItem) -> Void)? = nil, | ||
imageName: String? = nil, | ||
customActionView: AnyView? = nil | ||
) { | ||
self.title = title | ||
self.titleFont = titleFont | ||
self.message = message | ||
self.messageFont = messageFont | ||
self.list = list | ||
self.listAction = listAction | ||
self.imageName = imageName | ||
self.customActionView = customActionView | ||
self.orientation = orientation | ||
|
||
var itemsToAnimate: [DisplayableTypes] = [] | ||
if title != nil { | ||
itemsToAnimate.append(.title) | ||
} | ||
itemsToAnimate.append(.message) | ||
if !list.isEmpty { | ||
itemsToAnimate.append(.list) | ||
} | ||
if imageName != nil { | ||
itemsToAnimate.append(.image) | ||
} | ||
if customActionView != nil { | ||
itemsToAnimate.append(.button) | ||
} | ||
|
||
self.itemsToAnimate = itemsToAnimate | ||
} | ||
|
||
@State private var startTypingTitle: Bool = false | ||
@State private var startTypingMessage: Bool = false | ||
@State private var nonTypingAnimatableItems: NonTypingAnimatableItems = [] | ||
|
||
public var body: some View { | ||
Group { | ||
if orientation == .verticalStack { | ||
VStack { | ||
typingElements | ||
nonTypingElements | ||
} | ||
} else if case .horizontalStack(let alignment) = orientation { | ||
HStack(alignment: alignment, spacing: 10) { | ||
typingElements | ||
Spacer() | ||
nonTypingElements | ||
} | ||
} | ||
} | ||
.onAppear { | ||
Task { @MainActor in | ||
try await Task.sleep(interval: 0.3) | ||
startAnimating() | ||
} | ||
} | ||
} | ||
|
||
@ViewBuilder var typingElements: some View { | ||
VStack(alignment: .leading, spacing: 16) { | ||
titleView | ||
messageView | ||
} | ||
} | ||
|
||
@ViewBuilder var nonTypingElements: some View { | ||
VStack(alignment: .leading, spacing: 16) { | ||
listView | ||
.visibility(nonTypingAnimatableItems.contains(.list) ? .visible : .invisible) | ||
imageView | ||
.visibility(nonTypingAnimatableItems.contains(.image) ? .visible : .invisible) | ||
actionView | ||
.visibility(nonTypingAnimatableItems.contains(.button) ? .visible : .invisible) | ||
} | ||
} | ||
|
||
@ViewBuilder | ||
private var titleView: some View { | ||
if let title { | ||
let animatingText = AnimatableTypingText(title, startAnimating: $startTypingTitle, onTypingFinished: { | ||
startTypingMessage = true | ||
}) | ||
|
||
if let titleFont { | ||
animatingText.font(titleFont) | ||
} else { | ||
animatingText | ||
} | ||
} | ||
} | ||
|
||
@ViewBuilder | ||
private var messageView: some View { | ||
let animatingText = AnimatableTypingText(message, startAnimating: $startTypingMessage, onTypingFinished: { | ||
animateNonTypingItems() | ||
}) | ||
if let messageFont { | ||
animatingText.font(messageFont) | ||
} else { | ||
animatingText | ||
} | ||
} | ||
|
||
@ViewBuilder | ||
private var listView: some View { | ||
if let listAction { | ||
ContextualOnboardingListView(list: list, action: listAction) | ||
} | ||
} | ||
|
||
@ViewBuilder | ||
private var imageView: some View { | ||
if let imageName { | ||
HStack { | ||
Spacer() | ||
Image(imageName) | ||
Spacer() | ||
} | ||
} | ||
} | ||
|
||
@ViewBuilder | ||
private var actionView: some View { | ||
if let customActionView { | ||
customActionView | ||
} | ||
} | ||
|
||
enum DisplayableTypes { | ||
case title | ||
case message | ||
case list | ||
case image | ||
case button | ||
} | ||
} | ||
|
||
struct NonTypingAnimatableItems: OptionSet { | ||
let rawValue: Int | ||
|
||
static let list = NonTypingAnimatableItems(rawValue: 1 << 0) | ||
static let image = NonTypingAnimatableItems(rawValue: 1 << 1) | ||
static let button = NonTypingAnimatableItems(rawValue: 1 << 2) | ||
} | ||
|
||
// MARK: - Auxiliary Functions | ||
|
||
extension ContextualDaxDialogContent { | ||
|
||
private func startAnimating() { | ||
if itemsToAnimate.contains(.title) { | ||
startTypingTitle = true | ||
} else if itemsToAnimate.contains(.message) { | ||
startTypingMessage = true | ||
} | ||
} | ||
|
||
private func animateNonTypingItems() { | ||
// Remove typing items and animate sequentially non typing items | ||
let nonTypingItems = itemsToAnimate.filter { $0 != .title && $0 != .message } | ||
|
||
nonTypingItems.enumerated().forEach { index, item in | ||
let delayForItem = Metrics.animationDelay * Double(index + 1) | ||
withAnimation(.easeIn(duration: Metrics.animationDuration).delay(delayForItem)) { | ||
switch item { | ||
case .title, .message: | ||
// Typing items. they don't need to animate sequentially. | ||
break | ||
case .list: | ||
nonTypingAnimatableItems.insert(.list) | ||
case .image: | ||
nonTypingAnimatableItems.insert(.image) | ||
case .button: | ||
nonTypingAnimatableItems.insert(.button) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
// MARK: - Metrics | ||
|
||
enum Metrics { | ||
static let animationDuration = 0.25 | ||
static let animationDelay = 0.3 | ||
} | ||
|
||
// MARK: - Preview | ||
|
||
#Preview("Intro Dialog - text") { | ||
let fullString = "Instantly clear your browsing activity with the Fire Button.\n\n Give it a try! ☝️" | ||
let boldString = "Fire Button." | ||
|
||
let attributedString = NSMutableAttributedString(string: fullString) | ||
let boldFontAttribute: [NSAttributedString.Key: Any] = [ | ||
.font: PlatformFont.systemFont(ofSize: 15, weight: .bold) | ||
] | ||
|
||
if let boldRange = fullString.range(of: boldString) { | ||
let nsBoldRange = NSRange(boldRange, in: fullString) | ||
attributedString.addAttributes(boldFontAttribute, range: nsBoldRange) | ||
} | ||
|
||
return ContextualDaxDialogContent(message: attributedString) | ||
.padding() | ||
.preferredColorScheme(.light) | ||
} | ||
|
||
#Preview("Intro Dialog - text and button") { | ||
let contextualText = NSMutableAttributedString(string: "Sabrina is the best\n\nBelieve me! ☝️") | ||
return ContextualDaxDialogContent( | ||
message: contextualText, | ||
customActionView: AnyView(Button("Got it!", action: {}))) | ||
.padding() | ||
.preferredColorScheme(.light) | ||
} | ||
|
||
#Preview("Intro Dialog - title, text, image and button") { | ||
let contextualText = NSMutableAttributedString(string: "Sabrina is the best\n\nBelieve me! ☝️") | ||
return ContextualDaxDialogContent( | ||
title: "Who is the best?", | ||
message: contextualText, | ||
imageName: "Sync-Desktop-New-128", | ||
customActionView: AnyView(Button("Got it!", action: {}))) | ||
.padding() | ||
.preferredColorScheme(.light) | ||
} | ||
|
||
#Preview("Intro Dialog - title, text, list") { | ||
let contextualText = NSMutableAttributedString(string: "Sabrina is the best!\n\n Alessandro is ok I guess...") | ||
let list = [ | ||
ContextualOnboardingListItem.search(title: "Search"), | ||
ContextualOnboardingListItem.site(title: "Website"), | ||
ContextualOnboardingListItem.surprise(title: "Surprise", visibleTitle: "Surpeise me!"), | ||
] | ||
return ContextualDaxDialogContent( | ||
title: "Who is the best?", | ||
message: contextualText, | ||
list: list, | ||
listAction: { _ in }) | ||
.padding() | ||
.preferredColorScheme(.light) | ||
} | ||
|
||
#Preview("en_GB list") { | ||
ContextualDaxDialogContent(title: "title", | ||
message: NSAttributedString(string: "this is a message"), | ||
list: OnboardingSuggestedSitesProvider(countryProvider: Locale(identifier: "en_GB"), surpriseItemTitle: "surperise").list, | ||
listAction: { _ in }) | ||
.padding() | ||
} | ||
|
||
#Preview("en_US list") { | ||
ContextualDaxDialogContent(title: "title", | ||
message: NSAttributedString(string: "this is a message"), | ||
list: OnboardingSuggestedSitesProvider(countryProvider: Locale(identifier: "en_US"), surpriseItemTitle: "surprise").list, | ||
listAction: { _ in }) | ||
.padding() | ||
} |
Oops, something went wrong.