Skip to content

Commit

Permalink
iOS AI Chat - Improve show/dismiss animation (#3741)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204167627774280/1208991512395325/f

**Description**:
Allow user to drag the AI Chat view controller down by the title bar
  • Loading branch information
Bunn authored Dec 20, 2024
1 parent c4ac58d commit 1411032
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 100 deletions.
Binary file not shown.
9 changes: 5 additions & 4 deletions DuckDuckGo/MainViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2990,4 +2987,8 @@ extension MainViewController: AIChatViewControllerDelegate {
loadUrlInNewTab(url, inheritedAttribution: nil)
viewController.dismiss(animated: true)
}

func aiChatViewControllerDidFinish(_ viewController: AIChatViewController) {
viewController.dismiss(animated: true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
])
}

Expand All @@ -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)
Expand All @@ -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() {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -39,16 +41,22 @@ 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
transitionContext.completeTransition(finished)
})
}
}

class RoundedPageSheetDismissalAnimator: NSObject, UIViewControllerAnimatedTransitioning {
private var animator: UIViewPropertyAnimator?

func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return AnimatorConstants.duration
}
Expand All @@ -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
}
}
1 change: 0 additions & 1 deletion DuckDuckGo/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 0 additions & 3 deletions DuckDuckGo/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";

Expand Down
6 changes: 6 additions & 0 deletions LocalPackages/AIChat/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
]
Expand Down
Loading

0 comments on commit 1411032

Please sign in to comment.