diff --git a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf index b806da0f59..731eeaddaa 100644 Binary files a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf and b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf differ diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 35c07f3865..cb0c237d9d 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -1712,13 +1712,10 @@ class MainViewController: UIViewController { } private func openAIChat() { - let logoImage = UIImage(named: "Logo") - let title = UserText.aiChatTitle + let roundedPageSheet = RoundedPageSheetContainerViewController( contentViewController: aiChatViewController, - logoImage: logoImage, - title: title, allowedOrientation: .portrait) present(roundedPageSheet, animated: true, completion: nil) @@ -2990,4 +2987,8 @@ extension MainViewController: AIChatViewControllerDelegate { loadUrlInNewTab(url, inheritedAttribution: nil) viewController.dismiss(animated: true) } + + func aiChatViewControllerDidFinish(_ viewController: AIChatViewController) { + viewController.dismiss(animated: true) + } } diff --git a/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift index a9cb54dc7e..3ce8888906 100644 --- a/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift +++ b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetContainerViewController.swift @@ -21,21 +21,14 @@ import UIKit final class RoundedPageSheetContainerViewController: UIViewController { let contentViewController: UIViewController - private let logoImage: UIImage? - private let titleText: String private let allowedOrientation: UIInterfaceOrientationMask + let backgroundView = UIView() - private lazy var titleBarView: TitleBarView = { - let titleBarView = TitleBarView(logoImage: logoImage, title: titleText) { [weak self] in - self?.closeController() - } - return titleBarView - }() + private var interactiveDismissalTransition: UIPercentDrivenInteractiveTransition? + private var isInteractiveDismissal = false - init(contentViewController: UIViewController, logoImage: UIImage?, title: String, allowedOrientation: UIInterfaceOrientationMask = .all) { + init(contentViewController: UIViewController, allowedOrientation: UIInterfaceOrientationMask = .all) { self.contentViewController = contentViewController - self.logoImage = logoImage - self.titleText = title self.allowedOrientation = allowedOrientation super.init(nibName: nil, bundle: nil) modalPresentationStyle = .custom @@ -60,21 +53,52 @@ final class RoundedPageSheetContainerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .black + view.backgroundColor = .clear - setupTitleBar() + setupBackgroundView() setupContentViewController() } - private func setupTitleBar() { - view.addSubview(titleBarView) - titleBarView.translatesAutoresizingMaskIntoConstraints = false + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + let progress = translation.y / view.bounds.height + + switch gesture.state { + case .began: + isInteractiveDismissal = true + interactiveDismissalTransition = UIPercentDrivenInteractiveTransition() + dismiss(animated: true, completion: nil) + case .changed: + interactiveDismissalTransition?.update(progress) + case .ended, .cancelled: + let shouldDismiss = progress > 0.3 || velocity.y > 1000 + if shouldDismiss { + interactiveDismissalTransition?.finish() + } else { + interactiveDismissalTransition?.cancel() + UIView.animate(withDuration: 0.2, animations: { + self.view.transform = .identity + }) + } + isInteractiveDismissal = false + interactiveDismissalTransition = nil + default: + break + } + } + + private func setupBackgroundView() { + view.addSubview(backgroundView) + + backgroundView.backgroundColor = .black + backgroundView.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) + backgroundView.topAnchor.constraint(equalTo: view.topAnchor), + backgroundView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } @@ -84,7 +108,7 @@ final class RoundedPageSheetContainerViewController: UIViewController { contentViewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - contentViewController.view.topAnchor.constraint(equalTo: titleBarView.bottomAnchor), // Below the title bar + contentViewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), contentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), contentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), contentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) @@ -95,6 +119,9 @@ final class RoundedPageSheetContainerViewController: UIViewController { contentViewController.view.clipsToBounds = true contentViewController.didMove(toParent: self) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + contentViewController.view.addGestureRecognizer(panGesture) } @objc func closeController() { @@ -110,70 +137,8 @@ extension RoundedPageSheetContainerViewController: UIViewControllerTransitioning 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?() + func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + return isInteractiveDismissal ? interactiveDismissalTransition : nil } } diff --git a/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift index 8f5f584591..85cd7d8787 100644 --- a/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift +++ b/DuckDuckGo/RoundedPageContainer/RoundedPageSheetPresentationAnimator.swift @@ -21,9 +21,11 @@ import UIKit enum AnimatorConstants { static let duration: TimeInterval = 0.4 + static let springDamping: CGFloat = 0.9 + static let springVelocity: CGFloat = 0.5 } -class RoundedPageSheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning { +final class RoundedPageSheetPresentationAnimator: NSObject, UIViewControllerAnimatedTransitioning { func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return AnimatorConstants.duration } @@ -39,7 +41,12 @@ class RoundedPageSheetPresentationAnimator: NSObject, UIViewControllerAnimatedTr toView.alpha = 0 contentView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height) - UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { + UIView.animate(withDuration: AnimatorConstants.duration, + delay: 0, + usingSpringWithDamping: AnimatorConstants.springDamping, + initialSpringVelocity: AnimatorConstants.springVelocity, + options: .curveEaseInOut, + animations: { toView.alpha = 1 contentView.transform = .identity }, completion: { finished in @@ -47,8 +54,9 @@ class RoundedPageSheetPresentationAnimator: NSObject, UIViewControllerAnimatedTr }) } } - class RoundedPageSheetDismissalAnimator: NSObject, UIViewControllerAnimatedTransitioning { + private var animator: UIViewPropertyAnimator? + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { return AnimatorConstants.duration } @@ -58,14 +66,54 @@ class RoundedPageSheetDismissalAnimator: NSObject, UIViewControllerAnimatedTrans let fromView = fromViewController.view, let contentView = fromViewController.contentViewController.view else { return } + let fromBackgroundView = fromViewController.backgroundView let containerView = transitionContext.containerView - UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: { - fromView.alpha = 0 + UIView.animate(withDuration: AnimatorConstants.duration, + delay: 0, + usingSpringWithDamping: AnimatorConstants.springDamping, + initialSpringVelocity: AnimatorConstants.springVelocity, + options: .curveEaseInOut, + animations: { + fromBackgroundView.alpha = 0 contentView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height) }, completion: { finished in fromView.removeFromSuperview() transitionContext.completeTransition(finished) }) } + + func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning) -> UIViewImplicitlyAnimating { + if let existingAnimator = animator { + return existingAnimator + } + + guard let fromViewController = transitionContext.viewController(forKey: .from) as? RoundedPageSheetContainerViewController, + let fromView = fromViewController.view, + let contentView = fromViewController.contentViewController.view else { + fatalError("Invalid view controller setup") + } + + let containerView = transitionContext.containerView + let fromBackgroundView = fromViewController.backgroundView + + let animator = UIViewPropertyAnimator(duration: AnimatorConstants.duration, + dampingRatio: AnimatorConstants.springDamping) { + fromBackgroundView.alpha = 0 + contentView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height) + } + + animator.addCompletion { position in + switch position { + case .end: + fromView.removeFromSuperview() + transitionContext.completeTransition(true) + default: + transitionContext.completeTransition(false) + } + } + + self.animator = animator + return animator + } } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 86a2337975..c6e7a5722f 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1352,7 +1352,6 @@ But if you *do* want a peek under the hood, you can find more information about static let duckPlayerContingencyMessageCTA = NSLocalizedString("duck-player.video-contingency-cta", value: "Learn More", comment: "Button for the message explaining to the user that Duck Player is not available so the user can learn more") // MARK: - AI Chat - public static let aiChatTitle = NSLocalizedString("aichat.title", value: "DuckDuckGo AI Chat", comment: "Title for DuckDuckGo AI Chat. Should not be translated") public static let aiChatFeatureName = NSLocalizedString("aichat.settings.title", value: "AI Chat", comment: "Settings screen cell text for AI Chat settings") public static let aiChatSettingsEnableFooter = NSLocalizedString("aichat.settings.enable.footer", value: "Turning this off will hide the AI Chat feature in the DuckDuckGo app.", comment: "Footer text for AI Chat settings") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index d1bf6015ea..022fc631d0 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -158,9 +158,6 @@ /* Settings screen cell text for AI Chat settings */ "aichat.settings.title" = "AI Chat"; -/* Title for DuckDuckGo AI Chat. Should not be translated */ -"aichat.title" = "DuckDuckGo AI Chat"; - /* No comment provided by engineer. */ "alert.message.bookmarkAll" = "Existing bookmarks will not be duplicated."; diff --git a/LocalPackages/AIChat/Package.swift b/LocalPackages/AIChat/Package.swift index 478b4b4705..29f3abc3d5 100644 --- a/LocalPackages/AIChat/Package.swift +++ b/LocalPackages/AIChat/Package.swift @@ -13,9 +13,15 @@ let package = Package( targets: ["AIChat"] ), ], + dependencies: [ + .package(url: "https://github.com/duckduckgo/DesignResourcesKit", exact: "3.3.0") + ], targets: [ .target( name: "AIChat", + dependencies: [ + "DesignResourcesKit", + ], resources: [ .process("Resources/Assets.xcassets") ] diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 31d394969c..2d4bddede5 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -16,6 +16,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // + import UIKit import Combine import WebKit @@ -28,6 +29,11 @@ public protocol AIChatViewControllerDelegate: AnyObject { /// - viewController: The `AIChatViewController` instance making the request. /// - url: The `URL` that is requested to be loaded. func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) + + /// Tells the delegate that the `AIChatViewController` has finished its task. + /// + /// - Parameter viewController: The `AIChatViewController` instance that has finished. + func aiChatViewControllerDidFinish(_ viewController: AIChatViewController) } public final class AIChatViewController: UIViewController { @@ -35,6 +41,17 @@ public final class AIChatViewController: UIViewController { private let chatModel: AIChatViewModeling private var webViewController: AIChatWebViewController? + private lazy var titleBarView: TitleBarView = { + let title = UserText.aiChatTitle + + let titleBarView = TitleBarView(title: UserText.aiChatTitle) { [weak self] in + guard let self = self else { return } + self.delegate?.aiChatViewControllerDidFinish(self) + } + return titleBarView + }() + + /// Initializes a new instance of `AIChatViewController` with the specified remote settings and web view configuration. /// /// - Parameters: @@ -62,6 +79,7 @@ extension AIChatViewController { public override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .black + setupTitleBar() } public override func viewWillAppear(_ animated: Bool) { @@ -87,6 +105,18 @@ extension AIChatViewController { // MARK: - Views Setup extension AIChatViewController { + 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: 68) + ]) + } + private func addWebViewController() { guard webViewController == nil else { return } @@ -99,7 +129,7 @@ extension AIChatViewController { viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + viewController.view.topAnchor.constraint(equalTo: titleBarView.bottomAnchor), viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) diff --git a/LocalPackages/AIChat/Sources/AIChat/TitleBarView.swift b/LocalPackages/AIChat/Sources/AIChat/TitleBarView.swift new file mode 100644 index 0000000000..16f8ad4b5a --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/TitleBarView.swift @@ -0,0 +1,90 @@ +// +// TitleBarView.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 +import DesignResourcesKit + +final class TitleBarView: UIView { + private let titleLabel: UILabel + private let closeButton: UIButton + private let handleBar: UIView + private var closeAction: (() -> Void)? + + init(title: String, closeAction: @escaping () -> Void) { + titleLabel = UILabel() + closeButton = UIButton(type: .system) + handleBar = UIView() + + self.closeAction = closeAction + + super.init(frame: .zero) + + setupView(title: title) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView(title: String) { + backgroundColor = .webViewBackgroundColor + + handleBar.backgroundColor = UIColor(white: 0.5, alpha: 0.5) + handleBar.layer.cornerRadius = Constants.handlebarHeight / 2 + handleBar.translatesAutoresizingMaskIntoConstraints = false + + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = UIColor(designSystemColor: .textPrimary) + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + closeButton.setImage(UIImage(named: "Close-24"), for: .normal) + closeButton.tintColor = UIColor(designSystemColor: .icons) + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + + addSubview(handleBar) + addSubview(titleLabel) + addSubview(closeButton) + + NSLayoutConstraint.activate([ + handleBar.topAnchor.constraint(equalTo: topAnchor, constant: 8), + handleBar.centerXAnchor.constraint(equalTo: centerXAnchor), + handleBar.widthAnchor.constraint(equalToConstant: Constants.handlebarWidth), + handleBar.heightAnchor.constraint(equalToConstant: Constants.handlebarHeight), + + titleLabel.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: handleBar.bottomAnchor, constant: 30), + + closeButton.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -8), + closeButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + closeButton.widthAnchor.constraint(equalToConstant: Constants.closeButtonSize), + closeButton.heightAnchor.constraint(equalToConstant: Constants.closeButtonSize) + ]) + } + + @objc private func closeButtonTapped() { + closeAction?() + } +} + +private enum Constants { + static let closeButtonSize: CGFloat = 44 + static let handlebarHeight: CGFloat = 3 + static let handlebarWidth: CGFloat = 42 +} diff --git a/LocalPackages/AIChat/Sources/AIChat/UserText.swift b/LocalPackages/AIChat/Sources/AIChat/UserText.swift index d33e745d2d..e88c31793c 100644 --- a/LocalPackages/AIChat/Sources/AIChat/UserText.swift +++ b/LocalPackages/AIChat/Sources/AIChat/UserText.swift @@ -20,5 +20,5 @@ import Foundation public struct UserText { - public static let aiChatTitle = NSLocalizedString("aichat.title", value: "DuckDuckGo AI Chat", comment: "Title for DuckDuckGo AI Chat. Should not be translated") + public static let aiChatTitle = NSLocalizedString("aichat.title", value: "Duck.ai", comment: "Title for DuckDuckGo AI Chat. Should not be translated") }