From b2977540e05fe9d95f70dcdae8150781528d70b7 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 24 Feb 2023 18:08:13 -0500 Subject: [PATCH] Add profile moved banner Closes #284 --- Tusker.xcodeproj/project.pbxproj | 4 + .../ProfileHeaderMovedOverlayView.swift | 158 ++++++++++++++++++ .../Profile Header/ProfileHeaderView.swift | 68 +++++++- 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 Tusker/Views/Profile Header/ProfileHeaderMovedOverlayView.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index b6b91aee..3b56d475 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -223,6 +223,7 @@ D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; }; D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; }; D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; }; + D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; }; D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; }; D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; }; D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; }; @@ -641,6 +642,7 @@ D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = ""; }; D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = ""; }; D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = ""; }; + D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = ""; }; D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = ""; }; D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = ""; }; D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = ""; }; @@ -1208,6 +1210,7 @@ D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */, D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */, D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */, + D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */, ); path = "Profile Header"; sourceTree = ""; @@ -2229,6 +2232,7 @@ D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, + D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, diff --git a/Tusker/Views/Profile Header/ProfileHeaderMovedOverlayView.swift b/Tusker/Views/Profile Header/ProfileHeaderMovedOverlayView.swift new file mode 100644 index 00000000..0cc03f6e --- /dev/null +++ b/Tusker/Views/Profile Header/ProfileHeaderMovedOverlayView.swift @@ -0,0 +1,158 @@ +// +// ProfileHeaderMovedOverlayView.swift +// Tusker +// +// Created by Shadowfacts on 2/23/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit + +class ProfileHeaderMovedOverlayView: UIView { + + private var movedToID: String! + weak var delegate: TuskerNavigationDelegate? + + var collapse: (() -> Void)? + + private var avatarImageView: CachedImageView! + private var displayNameLabel: EmojiLabel! + private var usernameLabel: UILabel! + private(set) var collapseButton: UIButton! + + init() { + super.init(frame: .zero) + + let blur = UIBlurEffect(style: .systemUltraThinMaterial) + let blurView = UIVisualEffectView(effect: blur) + blurView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurView) + + let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label) + let vibrancyView = UIVisualEffectView(effect: vibrancy) + vibrancyView.translatesAutoresizingMaskIntoConstraints = false + blurView.contentView.addSubview(vibrancyView) + + let label = UILabel() + label.text = "This account has moved to" + label.font = .preferredFont(forTextStyle: .title3).withTraits(.traitBold) + label.adjustsFontForContentSizeCategory = true + label.textColor = .label + + avatarImageView = CachedImageView(cache: .avatars) + avatarImageView.layer.masksToBounds = true + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50 + avatarImageView.addInteraction(UIPointerInteraction(delegate: self)) + avatarImageView.isUserInteractionEnabled = true + + displayNameLabel = EmojiLabel() + displayNameLabel.adjustsFontForContentSizeCategory = true + displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue, + ] + ]), size: 0) + + usernameLabel = UILabel() + usernameLabel.adjustsFontForContentSizeCategory = true + usernameLabel.font = .preferredFont(forTextStyle: .body) + usernameLabel.textColor = .secondaryLabel + + let nameVStack = UIStackView(arrangedSubviews: [ + displayNameLabel, + usernameLabel, + ]) + nameVStack.axis = .vertical + nameVStack.alignment = .leading + nameVStack.spacing = 4 + + let accountHStack = UIStackView(arrangedSubviews: [ + avatarImageView, + nameVStack, + ]) + accountHStack.axis = .horizontal + accountHStack.alignment = .top + accountHStack.spacing = 4 + accountHStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountTapped))) + + let stack = UIStackView(arrangedSubviews: [ + label, + accountHStack, + ]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + vibrancyView.contentView.addSubview(stack) + + var config = UIButton.Configuration.plain() + config.image = UIImage(systemName: "chevron.up") + collapseButton = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in + self.collapse?() + })) + collapseButton.accessibilityLabel = "Shrink banner" + collapseButton.isPointerInteractionEnabled = true + collapseButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(collapseButton) + + NSLayoutConstraint.activate([ + blurView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurView.topAnchor.constraint(equalTo: topAnchor), + blurView.bottomAnchor.constraint(equalTo: bottomAnchor), + + vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor), + vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor), + vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor), + vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor), + + stack.centerXAnchor.constraint(equalTo: vibrancyView.contentView.readableContentGuide.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: vibrancyView.contentView.centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: vibrancyView.contentView.readableContentGuide.leadingAnchor), + stack.trailingAnchor.constraint(lessThanOrEqualTo: vibrancyView.contentView.readableContentGuide.trailingAnchor), + stack.topAnchor.constraint(greaterThanOrEqualTo: vibrancyView.contentView.topAnchor), + stack.bottomAnchor.constraint(lessThanOrEqualTo: vibrancyView.contentView.bottomAnchor), + + avatarImageView.widthAnchor.constraint(equalToConstant: 50), + avatarImageView.heightAnchor.constraint(equalToConstant: 50), + + bottomAnchor.constraint(equalToSystemSpacingBelow: collapseButton.bottomAnchor, multiplier: 1), + collapseButton.centerXAnchor.constraint(equalTo: centerXAnchor), + ]) + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func preferencesChanged() { + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50 + } + + func updateUI(movedTo: AccountMO) { + movedToID = movedTo.id + + avatarImageView.update(for: movedTo.avatar) + displayNameLabel.text = movedTo.displayOrUserName + displayNameLabel.setEmojis(movedTo.emojis, identifier: movedTo.id) + usernameLabel.text = "@\(movedTo.acct)" + } + + @objc private func accountTapped() { + delegate?.selected(account: movedToID) + } + +} + +extension ProfileHeaderMovedOverlayView: UIPointerInteractionDelegate { + func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? { + return defaultRegion + } + + func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { + let preview = UITargetedPreview(view: interaction.view!) + return UIPointerStyle(effect: .lift(preview)) + } +} diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.swift b/Tusker/Views/Profile Header/ProfileHeaderView.swift index 5a8295f5..9f72f5ce 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderView.swift @@ -37,6 +37,7 @@ class ProfileHeaderView: UIView { @IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var followCountButton: UIButton! private(set) var pagesSegmentedControl: ScrollingSegmentedControl! + private var movedOverlayView: ProfileHeaderMovedOverlayView? var accountID: String! @@ -170,15 +171,64 @@ class ProfileHeaderView: UIView { followCountButton.setAttributedTitle(followCountTitle, for: .normal) followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers" - accessibilityElements = [ - displayNameLabel!, - usernameLabel!, - relationshipLabel!, - noteTextView!, - fieldsView!, - moreButton!, - pagesSegmentedControl!, - ] + if let movedTo = account.movedTo { + if let movedOverlayView { + movedOverlayView.updateUI(movedTo: movedTo) + } else { + let overlay = createMovedOverlayView(movedTo: movedTo) + movedOverlayView = overlay + + accessibilityElements = [ + overlay, + ] + } + } else { + movedOverlayView?.removeFromSuperview() + movedOverlayView = nil + + accessibilityElements = [ + displayNameLabel!, + usernameLabel!, + relationshipLabel!, + noteTextView!, + fieldsView!, + moreButton!, + pagesSegmentedControl!, + ] + } + } + + private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView { + let overlay = ProfileHeaderMovedOverlayView() + overlay.delegate = delegate + overlay.updateUI(movedTo: movedTo) + overlay.translatesAutoresizingMaskIntoConstraints = false + addSubview(overlay) + let bottomConstraint = overlay.bottomAnchor.constraint(equalTo: bottomAnchor) + NSLayoutConstraint.activate([ + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + overlay.topAnchor.constraint(equalTo: topAnchor), + bottomConstraint, + ]) + + overlay.collapse = { [weak self, weak overlay] in + guard let self, let overlay else { return } + bottomConstraint.isActive = false + overlay.bottomAnchor.constraint(equalTo: self.avatarContainerView.topAnchor, constant: -2).isActive = true + let animator = UIViewPropertyAnimator(duration: 0.35, dampingRatio: 0.8) + animator.addAnimations { + self.layoutIfNeeded() + overlay.collapseButton.layer.opacity = 0 + } + animator.addCompletion { _ in + overlay.collapseButton.layer.opacity = 1 + overlay.collapseButton?.removeFromSuperview() + } + animator.startAnimation() + } + + return overlay } private func updateRelationship() {