From 684a4d9e15ee1a9098068e0e42923f91ef2eb3dc Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 5 Dec 2024 13:38:06 -0300 Subject: [PATCH] AI Chat transition animation (#3682) Task/Issue URL: https://app.asana.com/0/1204167627774280/1208906546735883/f **Description**: Add new transition for AI Chat UI --- DuckDuckGo.xcodeproj/project.pbxproj | 16 ++ DuckDuckGo/MainViewController.swift | 15 +- ...ndedPageSheetContainerViewController.swift | 175 ++++++++++++++++++ ...RoundedPageSheetPresentationAnimator.swift | 71 +++++++ .../Public API/AIChatViewController.swift | 70 +------ 5 files changed, 279 insertions(+), 68 deletions(-) create mode 100644 DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift create mode 100644 DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 6a9f6a9398..bffe6bd5d8 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -169,6 +169,8 @@ 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3170048127A9504F00C03F35 /* DownloadMocks.swift */; }; 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */; }; 317CA3432CFF82E100F88848 /* SettingsAIChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */; }; + 317DF6082D01E7B900DE0145 /* RoundedPageSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317DF6072D01E7B900DE0145 /* RoundedPageSheetContainerViewController.swift */; }; + 317DF60B2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317DF60A2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift */; }; 317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; }; 31860A5B2C57ED2D005561F5 /* DuckPlayerStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */; }; 31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */; }; @@ -1535,6 +1537,8 @@ 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillInterfaceEmailTruncatorTests.swift; sourceTree = ""; }; 31794BFF2821DFB600F18633 /* DuckUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DuckUI; sourceTree = ""; }; 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAIChatView.swift; sourceTree = ""; }; + 317DF6072D01E7B900DE0145 /* RoundedPageSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedPageSheetContainerViewController.swift; sourceTree = ""; }; + 317DF60A2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedPageSheetPresentationAnimator.swift; sourceTree = ""; }; 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketplaceAdPostbackStorage.swift; sourceTree = ""; }; 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerStorage.swift; sourceTree = ""; }; 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsHeaderView.swift; sourceTree = ""; }; @@ -3710,6 +3714,15 @@ name = Utils; sourceTree = ""; }; + 317DF6092D01E7C400DE0145 /* RoundedPageContainer */ = { + isa = PBXGroup; + children = ( + 317DF60A2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift */, + 317DF6072D01E7B900DE0145 /* RoundedPageSheetContainerViewController.swift */, + ); + path = RoundedPageContainer; + sourceTree = ""; + }; 31951E9328230D8900CAF535 /* Shared */ = { isa = PBXGroup; children = ( @@ -6508,6 +6521,7 @@ F1D796ED1E7AE4090019D451 /* UserInterface */ = { isa = PBXGroup; children = ( + 317DF6092D01E7C400DE0145 /* RoundedPageContainer */, 859872221F5743AF00041CB8 /* FireAnimation */, 1E162603296840790004127F /* SwiftUI */, 982686AC2600C0850011A8D6 /* ActionMessageView.swift */, @@ -7954,6 +7968,7 @@ 8598D2E32CEB98B500C45685 /* FaviconUserScript.swift in Sources */, 8598D2E42CEB98B500C45685 /* FaviconSourcesProvider.swift in Sources */, BD862E052B30DB250073E2EE /* VPNFeedbackCategory.swift in Sources */, + 317DF60B2D01E7D600DE0145 /* RoundedPageSheetPresentationAnimator.swift in Sources */, 85AE6690209724120014CF04 /* NotificationView.swift in Sources */, BDE91CE02C6515420005CB74 /* UnifiedFeedbackFormViewModel.swift in Sources */, 1EA51376286596A000493C6A /* PrivacyIconLogic.swift in Sources */, @@ -8040,6 +8055,7 @@ BD862E072B30F5E30073E2EE /* VPNFeedbackSender.swift in Sources */, AA4D6A6A23DB87B1007E8790 /* AppIconManager.swift in Sources */, 8563A03C1F9288D600F04442 /* BrowserChromeManager.swift in Sources */, + 317DF6082D01E7B900DE0145 /* RoundedPageSheetContainerViewController.swift in Sources */, 980891A32237146B00313A70 /* Feedback.swift in Sources */, F1D796F01E7B07610019D451 /* BookmarksViewControllerCells.swift in Sources */, 9F9EE4D42C37BB1300D4118E /* OnboardingView+Landing.swift in Sources */, diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 3e09cddf25..c99718f5bf 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -187,14 +187,14 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? - private lazy var aiChatNavigationController: UINavigationController = { + private lazy var aiChatViewController: AIChatViewController = { let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, internalUserDecider: AppDependencyProvider.shared.internalUserDecider) let aiChatViewController = AIChatViewController(settings: settings, webViewConfiguration: WKWebViewConfiguration.persistent(), pixelHandler: AIChatPixelHandler()) aiChatViewController.delegate = self - return UINavigationController(rootViewController: aiChatViewController) + return aiChatViewController }() init( @@ -2359,8 +2359,15 @@ extension MainViewController: TabDelegate { } func tabDidRequestAIChat(tab: TabViewController) { - aiChatNavigationController.modalPresentationStyle = .fullScreen - tab.present(aiChatNavigationController, animated: true, completion: nil) + let logoImage = UIImage(named: "Logo") + let title = UserText.aiChatTitle + + let roundedPageSheet = RoundedPageSheetContainerViewController( + contentViewController: aiChatViewController, + logoImage: logoImage, + title: title) + + present(roundedPageSheet, animated: true, completion: nil) } func tabDidRequestBookmarks(tab: TabViewController) { diff --git a/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift new file mode 100644 index 0000000000..880f474802 --- /dev/null +++ b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift @@ -0,0 +1,175 @@ +// +// RoundedPageSheetContainerViewController.swift +// DuckDuckGo +// +// 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 UIKit + +final class RoundedPageSheetContainerViewController: UIViewController { + let contentViewController: UIViewController + private let logoImage: UIImage? + private let titleText: String + + private lazy var titleBarView: TitleBarView = { + let titleBarView = TitleBarView(logoImage: logoImage, title: titleText) { [weak self] in + self?.closeController() + } + return titleBarView + }() + + init(contentViewController: UIViewController, logoImage: UIImage?, title: String) { + self.contentViewController = contentViewController + self.logoImage = logoImage + self.titleText = title + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .custom + + transitioningDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + setupTitleBar() + setupContentViewController() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { _ in + // Update layout or constraints here + }, completion: nil) + } + + + private func setupTitleBar() { + view.addSubview(titleBarView) + titleBarView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + titleBarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + titleBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + titleBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + titleBarView.heightAnchor.constraint(equalToConstant: 44) + ]) + } + + private func setupContentViewController() { + addChild(contentViewController) + view.addSubview(contentViewController.view) + contentViewController.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + contentViewController.view.topAnchor.constraint(equalTo: titleBarView.bottomAnchor), // Below the title bar + contentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + contentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + contentViewController.view.layer.cornerRadius = 20 + contentViewController.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + contentViewController.view.clipsToBounds = true + + contentViewController.didMove(toParent: self) + } + + @objc func closeController() { + dismiss(animated: true, completion: nil) + } +} + +extension RoundedPageSheetContainerViewController: UIViewControllerTransitioningDelegate { + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return RoundedPageSheetPresentationAnimator() + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return RoundedPageSheetDismissalAnimator() + } +} + +final private class TitleBarView: UIView { + private let imageView: UIImageView + private let titleLabel: UILabel + private let closeButton: UIButton + + init(logoImage: UIImage?, title: String, closeAction: @escaping () -> Void) { + imageView = UIImageView(image: logoImage) + titleLabel = UILabel() + closeButton = UIButton(type: .system) + + super.init(frame: .zero) + + setupView(title: title, closeAction: closeAction) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView(title: String, closeAction: @escaping () -> Void) { + backgroundColor = .clear + + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + let imageSize: CGFloat = 28 + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageSize), + imageView.heightAnchor.constraint(equalToConstant: imageSize) + ]) + + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .white + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + closeButton.setImage(UIImage(named: "Close-24"), for: .normal) + closeButton.tintColor = .white + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + + addSubview(imageView) + addSubview(titleLabel) + addSubview(closeButton) + + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 16), + imageView.centerYAnchor.constraint(equalTo: centerYAnchor), + + titleLabel.leadingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 8), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + closeButton.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -16), + closeButton.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + self.closeAction = closeAction + } + + private var closeAction: (() -> Void)? + + @objc private func closeButtonTapped() { + closeAction?() + } +} diff --git a/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift new file mode 100644 index 0000000000..8f5f584591 --- /dev/null +++ b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift @@ -0,0 +1,71 @@ +// +// RoundedPageSheetPresentationAnimator.swift +// DuckDuckGo +// +// 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 UIKit + +enum AnimatorConstants { + static let duration: TimeInterval = 0.4 +} + +class RoundedPageSheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return AnimatorConstants.duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let toViewController = transitionContext.viewController(forKey: .to) as? RoundedPageSheetContainerViewController, + let toView = toViewController.view, + let contentView = toViewController.contentViewController.view else { return } + + let containerView = transitionContext.containerView + + containerView.addSubview(toView) + toView.alpha = 0 + contentView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height) + + UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { + toView.alpha = 1 + contentView.transform = .identity + }, completion: { finished in + transitionContext.completeTransition(finished) + }) + } +} + +class RoundedPageSheetDismissalAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return AnimatorConstants.duration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromViewController = transitionContext.viewController(forKey: .from) as? RoundedPageSheetContainerViewController, + let fromView = fromViewController.view, + let contentView = fromViewController.contentViewController.view else { return } + + let containerView = transitionContext.containerView + + UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { + fromView.alpha = 0 + contentView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height) + }, completion: { finished in + fromView.removeFromSuperview() + transitionContext.completeTransition(finished) + }) + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 436ec008c8..66d2d46fdb 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -67,9 +67,6 @@ extension AIChatViewController { public override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .black - - setupNavigationBar() - subscribeToCleanupPublisher() } @@ -84,6 +81,11 @@ extension AIChatViewController { chatModel.cancelTimer() } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + chatModel.startCleanupTimer() + } + public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() @@ -97,55 +99,6 @@ extension AIChatViewController { // MARK: - Views Setup extension AIChatViewController { - private func setupNavigationBar() { - guard let navigationController = navigationController else { return } - - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - appearance.backgroundColor = .clear - appearance.shadowImage = UIImage() - appearance.shadowColor = .clear - - navigationController.navigationBar.standardAppearance = appearance - navigationController.navigationBar.scrollEdgeAppearance = appearance - navigationController.navigationBar.compactAppearance = appearance - navigationController.navigationBar.isTranslucent = true - - let imageView = UIImageView(image: UIImage(named: "Logo")) - imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false - - let imageSize: CGFloat = 28 - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: imageSize), - imageView.heightAnchor.constraint(equalToConstant: imageSize) - ]) - - let titleLabel = UILabel() - titleLabel.text = UserText.aiChatTitle - titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) - titleLabel.textColor = .white - let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) - stackView.axis = .horizontal - stackView.spacing = 8 - stackView.alignment = .center - stackView.distribution = .fill - - navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stackView) - - let closeButton = UIBarButtonItem( - image: UIImage(named: "Close-24"), - style: .plain, - target: self, - action: #selector(closeAIChat) - ) - closeButton.accessibilityIdentifier = "aichat.close.button" - closeButton.tintColor = .white - - navigationItem.rightBarButtonItem = closeButton - } - - private func addWebViewController() { guard webViewController == nil else { return } @@ -158,17 +111,12 @@ extension AIChatViewController { viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - viewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - viewController.view.backgroundColor = .black - viewController.view.layer.cornerRadius = 20 - viewController.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - viewController.view.clipsToBounds = true - viewController.didMove(toParent: self) } @@ -190,16 +138,10 @@ extension AIChatViewController { self?.timerPixelHandler.markCleanup() } } - - @objc private func closeAIChat() { - chatModel.startCleanupTimer() - dismiss(animated: true) - } } extension AIChatViewController: AIChatWebViewControllerDelegate { func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) { delegate?.aiChatViewController(self, didRequestToLoad: url) - closeAIChat() } }