From acbbafb18f4ff1d54aa46f5f663402c1fd89f834 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 20:10:17 +0800 Subject: [PATCH] feat: handle profile avatar preview --- .../Protocol/AvatarConfigurableView.swift | 17 +++++++-- .../MediaPreviewViewController.swift | 2 ++ .../MediaPreview/MediaPreviewViewModel.swift | 28 ++++++++++++++- .../Image/MediaPreviewImageViewModel.swift | 6 ++-- .../Header/ProfileHeaderViewController.swift | 3 +- .../Header/View/ProfileHeaderView.swift | 32 ++++++++++++++++- .../Scene/Profile/ProfileViewController.swift | 36 ++++++++++++++++++- ...wViewControllerAnimatedTransitioning.swift | 18 ++++------ .../MediaPreviewTransitionItem.swift | 16 +++++++++ .../MediaPreviewableViewController.swift | 2 ++ 10 files changed, 138 insertions(+), 22 deletions(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 1c2c78da..f2e95491 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -51,7 +51,10 @@ extension AvatarConfigurableView { avatarConfigurableView(self, didFinishConfiguration: configuration) } - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) + let filter = ScaledToSizeWithRoundedCornersFilter( + size: Self.configurableAvatarImageSize, + radius: configuration.keepImageCorner ? 0 : Self.configurableAvatarImageCornerRadius + ) // set placeholder if no asset guard let avatarImageURL = configuration.avatarImageURL else { @@ -91,6 +94,12 @@ extension AvatarConfigurableView { runImageTransitionIfCached: false, completion: nil ) + + if Self.configurableAvatarImageCornerRadius > 0, configuration.keepImageCorner { + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + } } configureLayerBorder(view: avatarImageView, configuration: configuration) @@ -148,16 +157,20 @@ struct AvatarConfigurableViewConfiguration { let borderColor: UIColor? let borderWidth: CGFloat? + let keepImageCorner: Bool + init( avatarImageURL: URL?, placeholderImage: UIImage? = nil, borderColor: UIColor? = nil, - borderWidth: CGFloat? = nil + borderWidth: CGFloat? = nil, + keepImageCorner: Bool = true ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage self.borderColor = borderColor self.borderWidth = borderWidth + self.keepImageCorner = keepImageCorner } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 9a515799..01d33853 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -106,6 +106,8 @@ extension MediaPreviewViewController { mosaicImageViewContainer.setImageViews(alpha: 1) mosaicImageViewContainer.setImageView(alpha: 0, index: index) } + case .profileAvatar: + break } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 2fe7f832..6a0b749b 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -36,7 +36,7 @@ final class MediaPreviewViewModel: NSObject { switch entity.type { case .image: guard let url = URL(string: entity.url) else { continue } - let meta = MediaPreviewImageViewModel.StatusImagePreviewMeta(url: url, thumbnail: thumbnail) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() mediaPreviewImageViewController.viewModel = mediaPreviewImageModel @@ -52,12 +52,33 @@ final class MediaPreviewViewModel: NSObject { super.init() } + init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileAvatar(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.avatarImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + } extension MediaPreviewViewModel { enum PreviewItem { case status(StatusImagePreviewMeta) + case profileAvatar(ProfileAvatarImagePreviewMeta) case local(LocalImagePreviewMeta) } @@ -67,6 +88,11 @@ extension MediaPreviewViewModel { let preloadThumbnailImages: [UIImage?] } + struct ProfileAvatarImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + struct LocalImagePreviewMeta { let image: UIImage } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index d59cb577..0ba7d4dc 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -13,7 +13,7 @@ class MediaPreviewImageViewModel { // input let item: ImagePreviewItem - init(meta: StatusImagePreviewMeta) { + init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) } @@ -25,11 +25,11 @@ class MediaPreviewImageViewModel { extension MediaPreviewImageViewModel { enum ImagePreviewItem { - case status(StatusImagePreviewMeta) + case status(RemoteImagePreviewMeta) case local(LocalImagePreviewMeta) } - struct StatusImagePreviewMeta { + struct RemoteImagePreviewMeta { let url: URL let thumbnail: UIImage? } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 38695f34..bb89dab0 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -150,8 +150,7 @@ extension ProfileHeaderViewController { with: AvatarConfigurableViewConfiguration( avatarImageURL: image == nil ? url : nil, // set only when image empty placeholderImage: image, - borderColor: .white, - borderWidth: 2 + keepImageCorner: true // fit preview transitioning ) ) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 09d99c51..1f1f6b71 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,7 +10,8 @@ import UIKit import ActiveLabel import TwitterTextEditor -protocol ProfileHeaderViewDelegate: class { +protocol ProfileHeaderViewDelegate: AnyObject { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) @@ -23,6 +24,8 @@ final class ProfileHeaderView: UIView { static let avatarImageViewSize = CGSize(width: 56, height: 56) static let avatarImageViewCornerRadius: CGFloat = 6 + static let avatarImageViewBorderColor = UIColor.white + static let avatarImageViewBorderWidth: CGFloat = 2 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) static let bannerImageViewPlaceholderColor = UIColor.systemGray @@ -51,6 +54,16 @@ final class ProfileHeaderView: UIView { return overlayView }() + let avatarImageViewBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = ProfileHeaderView.avatarImageViewBorderColor.cgColor + view.layer.borderWidth = ProfileHeaderView.avatarImageViewBorderWidth + return view + }() + let avatarImageView: UIImageView = { let imageView = UIImageView() let placeholderImage = UIImage @@ -188,6 +201,15 @@ extension ProfileHeaderView { avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), ]) + avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + ]) + editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.addSubview(editAvatarBackgroundView) NSLayoutConstraint.activate([ @@ -313,6 +335,9 @@ extension ProfileHeaderView { bioActiveLabel.delegate = self + let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer) + avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:))) relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) configure(state: .normal) @@ -372,6 +397,11 @@ extension ProfileHeaderView { assert(sender === relationshipActionButton) delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) } + + @objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) + } } // MARK: - ActiveLabelDelegate diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index e4be1eb1..ff8c38ed 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -10,7 +10,7 @@ import UIKit import Combine import ActiveLabel -final class ProfileViewController: UIViewController, NeedsDependency { +final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -18,6 +18,8 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) barButtonItem.tintColor = .white @@ -645,6 +647,38 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) { + guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let avatar = imageView.image else { return } + + let meta = MediaPreviewViewModel.ProfileAvatarImagePreviewMeta( + accountObjectID: mastodonUser.objectID, + preloadThumbnailImage: avatar + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .profileAvatar(profileHeaderView), + previewableViewController: self + ) + pushTransitionItem.aspectRatio = CGSize(width: 100, height: 100) + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.sourceImageViewCornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = avatar + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 06678330..1c54fadf 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -58,10 +58,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { // set to image hidden toVC.pagingViewConttroller.view.alpha = 0 // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` - switch transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageView(alpha: 0, index: toVC.viewModel.currentPage.value) - } + transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage.value) // Set transition image view assert(transitionItem.initialFrame != nil) @@ -143,16 +140,14 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } else { fromView.alpha = 0 } + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil } animator.addCompletion { position in self.transitionItem.snapshotTransitioning?.removeFromSuperview() - switch self.transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageViews(alpha: 1) - } + self.transitionItem.source.updateAppearance(position: position, index: nil) transitionContext.completeTransition(position == .end) } @@ -222,6 +217,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { animator.addAnimations { fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } } animator.addCompletion { position in @@ -231,10 +227,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 self.transitionItem.snapshotTransitioning?.removeFromSuperview() if position == .end { - switch self.transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageViews(alpha: 1) - } + // reset appearance + self.transitionItem.source.updateAppearance(position: position, index: nil) } fromVC.visualEffectView.effect = position == .end ? nil : blurEffect transitionContext.completeTransition(position == .end) diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 73afeeb8..48533cf3 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -20,6 +20,7 @@ class MediaPreviewTransitionItem: Identifiable { var aspectRatio: CGSize? var initialFrame: CGRect? = nil var sourceImageView: UIImageView? + var sourceImageViewCornerRadius: CGFloat? // target var targetFrame: CGRect? = nil @@ -41,5 +42,20 @@ class MediaPreviewTransitionItem: Identifiable { extension MediaPreviewTransitionItem { enum Source { case mosaic(MosaicImageViewContainer) + case profileAvatar(ProfileHeaderView) + + func updateAppearance(position: UIViewAnimatingPosition, index: Int?) { + let alpha: CGFloat = position == .end ? 1 : 0 + switch self { + case .mosaic(let mosaicImageViewContainer): + if let index = index { + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } else { + mosaicImageViewContainer.setImageViews(alpha: alpha) + } + case .profileAvatar(let profileHeaderView): + profileHeaderView.avatarImageView.alpha = alpha + } + } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index e5eb8ba6..c7080b21 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -19,6 +19,8 @@ extension MediaPreviewableViewController { guard index < mosaicImageViewContainer.imageViews.count else { return nil } let imageView = mosaicImageViewContainer.imageViews[index] return imageView.superview!.convert(imageView.frame, to: nil) + case .profileAvatar(let profileHeaderView): + return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) } } }