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
435 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|