From 5d91bcf8b58efd4bb2e41ad7cc50f686fc5ce4a6 Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Wed, 16 Nov 2022 22:49:49 -0500 Subject: [PATCH 1/4] Basic banner editing support --- .../Header/ProfileHeaderViewController.swift | 26 +++++++++++++----- .../Header/ProfileHeaderViewModel.swift | 5 ++++ .../View/ProfileHeaderView+ViewModel.swift | 7 +++++ .../Header/View/ProfileHeaderView.swift | 27 ++++++++++++++++--- Mastodon/Scene/Profile/ProfileViewModel.swift | 11 +++++--- 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index c5dbbecd4..26ecbb4db 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -60,7 +60,8 @@ final class ProfileHeaderViewController: UIViewController, NeedsDependency, Medi // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero - + + private var currentImageType = ImageType.avatar private(set) lazy var imagePicker: PHPickerViewController = { var configuration = PHPickerConfiguration() configuration.filter = .images @@ -125,7 +126,9 @@ extension ProfileHeaderViewController { } .store(in: &disposeBag) - profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createAvatarContextMenu() + profileHeaderView.editBannerButton.menu = createImageContextMenu(.banner) + profileHeaderView.editBannerButton.showsMenuAsPrimaryAction = true + profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createImageContextMenu(.avatar) profileHeaderView.editAvatarButtonOverlayIndicatorView.showsMenuAsPrimaryAction = true profileHeaderView.delegate = self @@ -173,7 +176,7 @@ extension ProfileHeaderViewController { profileHeaderView.viewModel.viewDidAppear.send() // set display after view appear - profileHeaderView.setupAvatarOverlayViews() + profileHeaderView.setupImageOverlayViews() } override func viewDidLayoutSubviews() { @@ -185,11 +188,15 @@ extension ProfileHeaderViewController { } extension ProfileHeaderViewController { - private func createAvatarContextMenu() -> UIMenu { + fileprivate enum ImageType { + case avatar + case banner + } + private func createImageContextMenu(_ type: ImageType) -> UIMenu { var children: [UIMenuElement] = [] let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) + self.currentImageType = type self.present(self.imagePicker, animated: true, completion: nil) } children.append(photoLibraryAction) @@ -197,6 +204,7 @@ extension ProfileHeaderViewController { let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) + self.currentImageType = type self.present(self.imagePickerController, animated: true, completion: nil) }) children.append(cameraAction) @@ -204,6 +212,7 @@ extension ProfileHeaderViewController { let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) + self.currentImageType = type self.present(self.documentPickerController, animated: true, completion: nil) } children.append(browseAction) @@ -443,7 +452,12 @@ extension ProfileHeaderViewController: UIDocumentPickerDelegate { // MARK: - CropViewControllerDelegate extension ProfileHeaderViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { - viewModel.profileInfoEditing.avatar = image + switch currentImageType { + case .banner: + viewModel.profileInfoEditing.header = image + case .avatar: + viewModel.profileInfoEditing.avatar = image + } cropViewController.dismiss(animated: true, completion: nil) } } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index 65f15efa7..2504153e2 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -52,6 +52,9 @@ final class ProfileHeaderViewModel { .sink { [weak self] account in guard let self = self else { return } guard let account = account else { return } + // banner + self.profileInfo.header = nil + self.profileInfoEditing.header = nil // avatar self.profileInfo.avatar = nil self.profileInfoEditing.avatar = nil @@ -72,6 +75,7 @@ final class ProfileHeaderViewModel { extension ProfileHeaderViewModel { class ProfileInfo { // input + @Published var header: UIImage? @Published var avatar: UIImage? @Published var name: String? @Published var note: String? @@ -99,6 +103,7 @@ extension ProfileHeaderViewModel: ProfileViewModelEditable { var isEdited: Bool { guard isEditing else { return false } + guard profileInfoEditing.header == nil else { return true } guard profileInfoEditing.avatar == nil else { return true } guard profileInfo.name == profileInfoEditing.name else { return true } guard profileInfo.note == profileInfoEditing.note else { return true } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift index c51ccfab3..ceb291bac 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift @@ -262,22 +262,29 @@ extension ProfileHeaderView { animator.addAnimations { self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor self.nameTextFieldBackgroundView.backgroundColor = .clear + self.editBannerButton.alpha = 0 self.editAvatarBackgroundView.alpha = 0 } animator.addCompletion { _ in + self.editBannerButton.isHidden = true self.editAvatarBackgroundView.isHidden = true + self.bannerImageViewSingleTapGestureRecognizer.isEnabled = true } case .editing: nameMetaText.textView.alpha = 0 nameTextField.isEnabled = true nameTextField.alpha = 1 + editBannerButton.isHidden = false + editBannerButton.alpha = 0 editAvatarBackgroundView.isHidden = false editAvatarBackgroundView.alpha = 0 bioMetaText.textView.backgroundColor = .clear + bannerImageViewSingleTapGestureRecognizer.isEnabled = false animator.addAnimations { self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color + self.editBannerButton.alpha = 1 self.editAvatarBackgroundView.alpha = 1 self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index a2194758b..a9941453b 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -50,6 +50,7 @@ final class ProfileHeaderView: UIView { return viewModel }() + let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer let bannerContainerView = UIView() let bannerImageView: UIImageView = { let imageView = UIImageView() @@ -101,7 +102,9 @@ final class ProfileHeaderView: UIView { return button }() - func setupAvatarOverlayViews() { + func setupImageOverlayViews() { + editBannerButton.tintColor = .white + editAvatarBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6) editAvatarButtonOverlayIndicatorView.tintColor = .white } @@ -113,6 +116,13 @@ final class ProfileHeaderView: UIView { return visualEffectView }() + let editBannerButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) + button.tintColor = .clear + return button + }() + let editAvatarBackgroundView: UIView = { let view = UIView() view.backgroundColor = .clear // set value after view appeared @@ -271,7 +281,17 @@ extension ProfileHeaderView { bannerImageViewOverlayVisualEffectView.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor), bannerImageViewOverlayVisualEffectView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor), ]) - + + editBannerButton.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.addSubview(editBannerButton) + NSLayoutConstraint.activate([ + editBannerButton.topAnchor.constraint(equalTo: bannerImageView.topAnchor), + editBannerButton.leadingAnchor.constraint(equalTo: bannerImageView.leadingAnchor), + editBannerButton.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor), + editBannerButton.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor), + ]) + bannerContainerView.isUserInteractionEnabled = true + // follows you followsYouBlurEffectView.translatesAutoresizingMaskIntoConstraints = false addSubview(followsYouBlurEffectView) @@ -455,8 +475,7 @@ extension ProfileHeaderView { statusDashboardView.delegate = self bioMetaText.textView.delegate = self bioMetaText.textView.linkDelegate = self - - let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + bannerImageView.addGestureRecognizer(bannerImageViewSingleTapGestureRecognizer) bannerImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.bannerImageViewDidPressed(_:))) diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index e23b465d4..1ca1d4bea 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -215,8 +215,11 @@ extension ProfileViewModel { let authenticationBox = authContext.mastodonAuthenticationBox let domain = authenticationBox.domain let authorization = authenticationBox.userAuthorization - - let _image: UIImage? = { + + // TODO: constrain size? + let _header: UIImage? = headerProfileInfo.header + + let _avatar: UIImage? = { guard let image = headerProfileInfo.avatar else { return nil } guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) @@ -233,8 +236,8 @@ extension ProfileViewModel { bot: nil, displayName: headerProfileInfo.name, note: headerProfileInfo.note, - avatar: _image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, - header: nil, + avatar: _avatar.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, + header: _header.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, locked: nil, source: nil, fieldsAttributes: fieldsAttributes From 23a0943b12351eba8a98975cac69b4d886d7d33d Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Wed, 16 Nov 2022 22:52:44 -0500 Subject: [PATCH 2/4] Constrain size --- .../Scene/Profile/Header/ProfileHeaderViewModel.swift | 1 + Mastodon/Scene/Profile/ProfileViewModel.swift | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index 2504153e2..c66789cca 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -18,6 +18,7 @@ import MastodonUI final class ProfileHeaderViewModel { static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) + static let bannerImageMaxSizeInPixel = CGSize(width: 1500, height: 500) static let maxProfileFieldCount = 4 var disposeBag = Set() diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 1ca1d4bea..5c81c7920 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -217,7 +217,13 @@ extension ProfileViewModel { let authorization = authenticationBox.userAuthorization // TODO: constrain size? - let _header: UIImage? = headerProfileInfo.header + let _header: UIImage? = { + guard let image = headerProfileInfo.header else { return nil } + guard image.size.width <= ProfileHeaderViewModel.bannerImageMaxSizeInPixel.width else { + return image.af.imageScaled(to: ProfileHeaderViewModel.bannerImageMaxSizeInPixel) + } + return image + }() let _avatar: UIImage? = { guard let image = headerProfileInfo.avatar else { return nil } From 4af5d3910a7f2f3476fd205c0cfd9ed97669c7ae Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Wed, 16 Nov 2022 23:05:48 -0500 Subject: [PATCH 3/4] Wire up the header image to be actually displayed --- .../Header/ProfileHeaderViewController.swift | 11 ++++++++++- .../Header/View/ProfileHeaderView+ViewModel.swift | 14 ++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 26ecbb4db..1a0dda44e 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -159,6 +159,9 @@ extension ProfileHeaderViewController { viewModel.$isUpdating .assign(to: \.isUpdating, on: profileHeaderView.viewModel) .store(in: &disposeBag) + viewModel.profileInfoEditing.$header + .assign(to: \.headerImageEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) viewModel.profileInfoEditing.$avatar .assign(to: \.avatarImageEditing, on: profileHeaderView.viewModel) .store(in: &disposeBag) @@ -224,7 +227,13 @@ extension ProfileHeaderViewController { DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) cropController.delegate = self - cropController.setAspectRatioPreset(.presetSquare, animated: true) + switch self.currentImageType { + case .banner: + cropController.customAspectRatio = CGSize(width: 3, height: 1) + cropController.setAspectRatioPreset(.presetCustom, animated: true) + case .avatar: + cropController.setAspectRatioPreset(.presetSquare, animated: true) + } cropController.aspectRatioPickerButtonHidden = true cropController.aspectRatioLockEnabled = true pickerViewController.dismiss(animated: true, completion: { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift index ceb291bac..5095d7ac0 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift @@ -28,6 +28,7 @@ extension ProfileHeaderView { @Published var emojiMeta: MastodonContent.Emojis = [:] @Published var headerImageURL: URL? + @Published var headerImageEditing: UIImage? @Published var avatarImageURL: URL? @Published var avatarImageEditing: UIImage? @@ -61,14 +62,19 @@ extension ProfileHeaderView.ViewModel { func bind(view: ProfileHeaderView) { // header - Publishers.CombineLatest( + Publishers.CombineLatest4( $headerImageURL, + $headerImageEditing, + $isEditing, viewDidAppear ) - .sink { headerImageURL, _ in + .sink { headerImageURL, headerImageEditing, isEditing, _ in view.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) - guard let bannerImageURL = headerImageURL else { + let defaultPlaceholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + let placeholder = isEditing ? (headerImageEditing ?? defaultPlaceholder) : defaultPlaceholder + guard let bannerImageURL = headerImageURL, + !isEditing || headerImageEditing == nil + else { view.bannerImageView.image = placeholder return } From f4018935f90257e97bf03a6eb6c5025ef5ffcc1e Mon Sep 17 00:00:00 2001 From: Jed Fox Date: Thu, 17 Nov 2022 07:25:16 -0500 Subject: [PATCH 4/4] Re-add missing debug line --- Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 1a0dda44e..7ca819b41 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -199,6 +199,7 @@ extension ProfileHeaderViewController { var children: [UIMenuElement] = [] let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) self.currentImageType = type self.present(self.imagePicker, animated: true, completion: nil) }