Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

iOS AI Chat - Improve show/dismiss animation #3741

Merged
merged 8 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading