deep-linking-sample/Apps/Wikipedia/Wikipedia/Code/TalkPageReplyComposeController.swift
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

435 lines
18 KiB
Swift

import Foundation
import UIKit
import WMF
protocol TalkPageReplyComposeDelegate: AnyObject {
func closeReplyView()
func tappedPublish(text: String, commentViewModel: TalkPageCellCommentViewModel)
}
/// Class for coordinating talk page reply compose views
class TalkPageReplyComposeController {
enum ActionSheetStrings {
static let closeConfirmationTitle = WMFLocalizedString("talk-pages-reply-compose-close-confirmation-title", value: "Are you sure you want to discard this new reply?", comment: "Title of confirmation alert displayed to user when they attempt to close the new reply view after entering text. Please prioritize for de, ar and zh wikis.")
static let closeConfirmationDiscard = WMFLocalizedString("talk-pages-topic-compose-close-confirmation-discard", value: "Discard Reply", comment: "Title of discard action, displayed within a confirmation alert to user when they attempt to close the new topic view after entering title or body text. Please prioritize for de, ar and zh wikis.")
}
// viewController - the view controller that triggered the reply compose screen
// containerView - the view that contains the contentView. It has the drag handle and pan gesture attached.
// contentView - the view with the reply compose UI elements (close button, publish button, text views)
typealias ReplyComposableViewController = ViewController & TalkPageReplyComposeDelegate & TalkPageTextViewLinkHandling
private var viewController: ReplyComposableViewController?
private(set) var commentViewModel: TalkPageCellCommentViewModel?
private(set) var containerView: UIView?
private var containerViewTopConstraint: NSLayoutConstraint?
private var containerViewBottomConstraint: NSLayoutConstraint?
private var contentViewBottomConstraint: NSLayoutConstraint?
// Pan Gesture tracking properties
private var dragHandleView: UIView?
private var containerViewYUponDragBegin: CGFloat?
private(set) var contentView: TalkPageReplyComposeContentView?
private let containerPinnedTopSpacing = CGFloat(10)
private let contentTopSpacing = CGFloat(15)
enum DisplayMode {
case full
case partial
}
private var displayMode: DisplayMode = .partial
private weak var authenticationManager: WMFAuthenticationManager?
private weak var accessibilityFocusView: UIView?
// MARK: Public
func setupAndDisplay(in viewController: ReplyComposableViewController, commentViewModel: TalkPageCellCommentViewModel, authenticationManager: WMFAuthenticationManager?, accessibilityFocusView: UIView?) {
guard self.commentViewModel == nil else {
attemptChangeCommentViewModel(in: viewController, newCommentViewModel: commentViewModel)
return
}
self.viewController = viewController
self.commentViewModel = commentViewModel
self.authenticationManager = authenticationManager
self.accessibilityFocusView = accessibilityFocusView
setupViews(in: viewController, commentViewModel: commentViewModel)
apply(theme: viewController.theme)
if UserDefaults.standard.wmf_userHasOnboardedToContributingToTalkPages {
if UIAccessibility.isVoiceOverRunning {
UIAccessibility.post(notification: .screenChanged, argument: contentView)
}
}
}
func attemptChangeCommentViewModel(in viewController: ReplyComposableViewController, newCommentViewModel: TalkPageCellCommentViewModel) {
presentDismissConfirmationActionSheet(discardBlock: {
self.closeAndReset(completion: { _ in
self.setupAndDisplay(in: viewController, commentViewModel: newCommentViewModel, authenticationManager: self.authenticationManager, accessibilityFocusView: self.accessibilityFocusView)
})
})
}
func calculateLayout(in viewController: ReplyComposableViewController, newViewSize: CGSize? = nil, newKeyboardFrame: CGRect? = nil) {
guard containerView != nil else {
return
}
let keyboardHeight = newKeyboardFrame?.height ?? viewController.keyboardFrame?.height ?? 0
contentViewBottomConstraint?.constant = keyboardHeight
guard !shouldAlwaysPinToTop() else {
displayMode = .full
containerViewTopConstraint?.constant = containerPinnedTopSpacing
return
}
// Aim for compose view to take up 60% of the screen for portrait, 80% for landscape
let viewSize = newViewSize ?? viewController.view.bounds.size
let isLandscape = viewSize.height < viewSize.width
let topConstraintMultiplier = isLandscape ? 0.20 : 0.40
let potentialTopConstraint = viewSize.height * (topConstraintMultiplier)
// Add a little bit of extra padding if keyboard is still too tall
let amountDisplaying = viewSize.height - potentialTopConstraint - keyboardHeight
let extraPadding = max(0, 200 - amountDisplaying)
let finalTopConstraint = potentialTopConstraint - extraPadding
switch displayMode {
case .full:
containerViewTopConstraint?.constant = containerPinnedTopSpacing
case .partial:
containerViewTopConstraint?.constant = finalTopConstraint
}
}
func closeAndReset(completion: ((UIView?) -> Void)? = nil) {
contentView?.replyTextView.resignFirstResponder()
animateOff {
self.dragHandleView?.removeFromSuperview()
self.dragHandleView = nil
self.containerViewYUponDragBegin = nil
self.containerView?.removeFromSuperview()
self.containerView = nil
self.containerViewTopConstraint = nil
self.contentViewBottomConstraint = nil
self.containerViewBottomConstraint = nil
self.contentView?.removeFromSuperview()
self.contentView = nil
self.viewController = nil
self.commentViewModel = nil
self.displayMode = .partial
let accessibilityFocusView = self.accessibilityFocusView
self.accessibilityFocusView = nil
completion?(accessibilityFocusView)
}
}
var isLoading: Bool = false {
didSet {
contentView?.isLoading = isLoading
}
}
var isShowing: Bool {
return contentView != nil
}
// MARK: Private
private func setupViews(in viewController: ReplyComposableViewController, commentViewModel: TalkPageCellCommentViewModel) {
let containerView = UIView(frame: .zero)
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.accessibilityViewIsModal = true
addShadow(to: containerView)
addDragHandle(to: containerView)
viewController.view.addSubview(containerView)
// set constraints
let trailingConstraint = viewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
let bottomConstraint = viewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
let leadingConstraint = viewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor)
let topConstraint = containerView.topAnchor.constraint(equalTo: viewController.view.safeAreaLayoutGuide.topAnchor, constant: containerPinnedTopSpacing)
NSLayoutConstraint.activate([trailingConstraint, bottomConstraint, leadingConstraint, topConstraint])
self.containerViewTopConstraint = topConstraint
self.containerViewBottomConstraint = bottomConstraint
self.containerView = containerView
// sets more accurate top constraint
calculateLayout(in: viewController)
// add pan gesture
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userDidPanContainerView(_:)))
containerView.addGestureRecognizer(panGestureRecognizer)
addContentView(to: containerView, theme: viewController.theme, commentViewModel: commentViewModel, linkDelegate: viewController)
animateOn()
}
private func addShadow(to containerView: UIView) {
containerView.layer.masksToBounds = false
containerView.layer.shadowOffset = CGSize(width: 0, height: -2)
containerView.layer.shadowOpacity = 1.0
containerView.layer.shadowRadius = 5
containerView.layer.cornerRadius = 8
}
func addDragHandle(to containerView: UIView) {
let dragHandleView = UIView(frame: .zero)
dragHandleView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(dragHandleView)
let dragHandleHeight = CGFloat(5)
dragHandleView.cornerRadius = dragHandleHeight/2.5
NSLayoutConstraint.activate([
dragHandleView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4),
dragHandleView.widthAnchor.constraint(equalToConstant: 36),
dragHandleView.heightAnchor.constraint(equalToConstant: dragHandleHeight),
dragHandleView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor)
])
self.dragHandleView = dragHandleView
}
private func addContentView(to containerView: UIView, theme: Theme, commentViewModel: TalkPageCellCommentViewModel, linkDelegate: TalkPageTextViewLinkHandling) {
let contentView = TalkPageReplyComposeContentView(commentViewModel: commentViewModel, theme: theme, linkDelegate: linkDelegate)
contentView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(contentView)
contentView.closeButton.addTarget(self, action: #selector(attemptClose), for: .touchUpInside)
contentView.publishButton.addTarget(self, action: #selector(tappedPublish), for: .touchUpInside)
let bottomConstraint = containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
bottomConstraint.priority = UILayoutPriority(999)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: contentTopSpacing),
contentView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
bottomConstraint,
contentView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor)
])
self.contentViewBottomConstraint = bottomConstraint
self.contentView = contentView
}
private func shouldAlwaysPinToTop() -> Bool {
guard let viewController = viewController else {
return false
}
if UIAccessibility.isVoiceOverRunning {
return true
}
return viewController.view.traitCollection.verticalSizeClass == .compact
}
@objc fileprivate func userDidPanContainerView(_ gestureRecognizer: UIPanGestureRecognizer) {
guard let viewController = viewController,
let containerView = containerView else {
gestureRecognizer.state = .ended
return
}
let translationY = gestureRecognizer.translation(in: viewController.view).y
switch gestureRecognizer.state {
case .began:
let containerViewYUponDragBegin = containerView.frame.origin.y - viewController.view.safeAreaInsets.top
self.containerViewYUponDragBegin = containerViewYUponDragBegin
calculateTopConstraintUponDrag(translationY: translationY)
case .changed:
guard containerViewYUponDragBegin != nil else {
gestureRecognizer.state = .ended
return
}
calculateTopConstraintUponDrag(translationY: translationY)
case .ended:
if translationY < -50 {
displayMode = .full
calculateLayout(in: viewController)
} else if translationY > 50 {
// If swiping down fast enough, attempt to close.
let shouldAttemptClose = displayMode == .partial || (displayMode == .full && shouldAlwaysPinToTop())
if shouldAttemptClose && gestureRecognizer.velocity(in: containerView).y > 100 {
attemptClose()
} else {
displayMode = .partial
calculateLayout(in: viewController)
}
} else {
calculateLayout(in: viewController)
}
// reset top constraint
containerViewYUponDragBegin = nil
default:
break
}
viewController.view.setNeedsLayout()
UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseOut) {
viewController.view.layoutIfNeeded()
}
}
private func calculateTopConstraintUponDrag(translationY: CGFloat) {
guard let containerViewYUponDragBegin = containerViewYUponDragBegin else {
return
}
// MAYBETODO: Consider maxing or mining out this value
containerViewTopConstraint?.constant = containerViewYUponDragBegin + translationY
}
// MARK: - Animate on/off
private func animateOn() {
guard let viewController = viewController,
let containerView = containerView else {
return
}
// manually move container off screen before animating to final constraints
containerView.frame = CGRect(x: 0, y: viewController.view.bounds.height, width: viewController.view.bounds.width, height: viewController.view.bounds.height)
containerView.setNeedsLayout()
containerView.layoutIfNeeded()
// animate on screen
viewController.view.setNeedsLayout()
UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseOut) {
viewController.view.layoutIfNeeded()
}
}
private func animateOff(completion: @escaping () -> Void) {
guard let viewController = viewController else {
return
}
containerViewTopConstraint?.constant = viewController.view.bounds.height
containerViewBottomConstraint?.constant = -viewController.view.bounds.height
contentViewBottomConstraint?.constant = 0
viewController.view.setNeedsLayout()
UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseOut) {
viewController.view.layoutIfNeeded()
} completion: { _ in
completion()
}
}
func presentDismissConfirmationActionSheet(discardBlock: @escaping () -> Void) {
let alertController = UIAlertController(title: Self.ActionSheetStrings.closeConfirmationTitle, message: nil, preferredStyle: .actionSheet)
let discardAction = UIAlertAction(title: Self.ActionSheetStrings.closeConfirmationDiscard, style: .destructive) { _ in
discardBlock()
}
let keepEditingAction = UIAlertAction(title: CommonStrings.talkPageCloseConfirmationKeepEditing, style: .cancel) { _ in
guard let viewController = self.viewController else {
return
}
self.calculateLayout(in: viewController)
}
alertController.addAction(discardAction)
alertController.addAction(keepEditingAction)
alertController.popoverPresentationController?.sourceView = contentView?.closeButton
viewController?.present(alertController, animated: true)
}
// MARK: - ACTIONS
@objc func attemptClose() {
contentView?.resignFirstResponder()
if let replyText = contentView?.replyTextView.text,
!replyText.isEmpty {
presentDismissConfirmationActionSheet(discardBlock: {
self.viewController?.closeReplyView()
})
return
}
viewController?.closeReplyView()
}
@objc private func tappedPublish() {
guard let commentViewModel = commentViewModel,
let text = contentView?.replyTextView.text else {
assertionFailure("Comment view model or replyTextView text is empty. Publish button should have been disabled.")
return
}
contentView?.replyTextView.resignFirstResponder()
guard let authenticationManager = authenticationManager,
!authenticationManager.isLoggedIn else {
isLoading = true
viewController?.tappedPublish(text: text, commentViewModel: commentViewModel)
return
}
guard let theme = viewController?.theme else {
return
}
viewController?.wmf_showNotLoggedInUponPublishPanel(buttonTapHandler: { [weak self] buttonIndex in
switch buttonIndex {
case 0:
break
case 1:
self?.isLoading = true
self?.viewController?.tappedPublish(text: text, commentViewModel: commentViewModel)
default:
assertionFailure("Unrecognized button index in tap handler.")
}
}, theme: theme)
}
}
extension TalkPageReplyComposeController: Themeable {
func apply(theme: Theme) {
containerView?.backgroundColor = theme.colors.paperBackground
containerView?.layer.shadowColor = theme.colors.shadow.cgColor
dragHandleView?.backgroundColor = .gray675
contentView?.apply(theme: theme)
}
}