// // ProfileHeaderViewController.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-29. // import UIKit import Combine import CoreDataStack import PhotosUI import AlamofireImage import CropViewController import MastodonMeta import MetaTextKit import MastodonAsset import MastodonCore import MastodonUI import MastodonLocalization import TabBarPager protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) } final class ProfileHeaderViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { static let segmentedControlHeight: CGFloat = 50 static let headerMinHeight: CGFloat = segmentedControlHeight weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() var viewModel: ProfileHeaderViewModel! weak var delegate: ProfileHeaderViewControllerDelegate? weak var headerDelegate: TabBarPagerHeaderDelegate? let mediaPreviewTransitionController = MediaPreviewTransitionController() let titleView: DoubleTitleLabelNavigationBarTitleView = { let titleView = DoubleTitleLabelNavigationBarTitleView() titleView.titleLabel.textColor = .white titleView.titleLabel.textAttributes[.foregroundColor] = UIColor.white titleView.titleLabel.alpha = 0 titleView.subtitleLabel.textColor = .white titleView.subtitleLabel.alpha = 0 titleView.layer.masksToBounds = true return titleView }() let profileHeaderView = ProfileHeaderView() // private var isBannerPinned = false // 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 configuration.selectionLimit = 1 let imagePicker = PHPickerViewController(configuration: configuration) imagePicker.delegate = self return imagePicker }() private(set) lazy var imagePickerController: UIImagePickerController = { let imagePickerController = UIImagePickerController() imagePickerController.sourceType = .camera imagePickerController.delegate = self return imagePickerController }() private(set) lazy var documentPickerController: UIDocumentPickerViewController = { let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) documentPickerController.delegate = self return documentPickerController }() } extension ProfileHeaderViewController { override func viewDidLoad() { super.viewDidLoad() view.setContentHuggingPriority(.required - 1, for: .vertical) view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor ThemeService.shared.currentTheme .receive(on: DispatchQueue.main) .sink { [weak self] theme in guard let self = self else { return } self.view.backgroundColor = theme.systemBackgroundColor } .store(in: &disposeBag) // profileHeaderView.preservesSuperviewLayoutMargins = true profileHeaderView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(profileHeaderView) NSLayoutConstraint.activate([ profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor), ]) profileHeaderView.bioMetaText.delegate = self NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) .receive(on: DispatchQueue.main) .sink { [weak self] notification in guard let self = self else { return } guard let textField = notification.object as? UITextField else { return } self.viewModel.profileInfoEditing.name = textField.text } .store(in: &disposeBag) profileHeaderView.editBannerButton.menu = createImageContextMenu(.banner) profileHeaderView.editBannerButton.showsMenuAsPrimaryAction = true profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createImageContextMenu(.avatar) profileHeaderView.editAvatarButtonOverlayIndicatorView.showsMenuAsPrimaryAction = true profileHeaderView.delegate = self // bind viewModel viewModel.$isTitleViewContentOffsetSet .receive(on: DispatchQueue.main) .sink { [weak self] isTitleViewContentOffsetDidSet in guard let self = self else { return } self.titleView.titleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 self.titleView.subtitleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 } .store(in: &disposeBag) viewModel.$user .receive(on: DispatchQueue.main) .sink { [weak self] user in guard let self = self else { return } guard let user = user else { return } self.profileHeaderView.prepareForReuse() self.profileHeaderView.configuration(user: user) } .store(in: &disposeBag) viewModel.$relationshipActionOptionSet .assign(to: \.relationshipActionOptionSet, on: profileHeaderView.viewModel) .store(in: &disposeBag) viewModel.$isMyself .assign(to: \.isMyself, on: profileHeaderView.viewModel) .store(in: &disposeBag) viewModel.$isEditing .assign(to: \.isEditing, on: profileHeaderView.viewModel) .store(in: &disposeBag) 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) viewModel.profileInfoEditing.$name .assign(to: \.nameEditing, on: profileHeaderView.viewModel) .store(in: &disposeBag) viewModel.profileInfoEditing.$note .assign(to: \.noteEditing, on: profileHeaderView.viewModel) .store(in: &disposeBag) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) profileHeaderView.viewModel.viewDidAppear.send() // set display after view appear profileHeaderView.setupImageOverlayViews() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() headerDelegate?.viewLayoutDidUpdate(self) } } extension ProfileHeaderViewController { 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 } self.currentImageType = type self.present(self.imagePicker, animated: true, completion: nil) } children.append(photoLibraryAction) if UIImagePickerController.isSourceTypeAvailable(.camera) { 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 } self.currentImageType = type self.present(self.imagePickerController, animated: true, completion: nil) }) children.append(cameraAction) } 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 } self.currentImageType = type self.present(self.documentPickerController, animated: true, completion: nil) } children.append(browseAction) return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) } private func cropImage(image: UIImage, pickerViewController: UIViewController) { DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) cropController.delegate = self 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: { self.present(cropController, animated: true, completion: nil) }) } } } extension ProfileHeaderViewController { func updateHeaderContainerSafeAreaInset(_ inset: UIEdgeInsets) { containerSafeAreaInset = inset } func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) { // set title view offset let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset let transformY = max(0, titleViewContentOffset) titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) viewModel.isTitleViewDisplaying = transformY < titleView.containerView.frame.height viewModel.isTitleViewContentOffsetSet = true if progress > 0, throttle > 0 { // y = 1 - (x/t) // give: x = 0, y = 1 // x = t, y = 0 let alpha = 1 - progress/throttle setProfileAvatar(alpha: alpha) } else { setProfileAvatar(alpha: 1) } } private func setProfileAvatar(alpha: CGFloat) { profileHeaderView.avatarImageViewBackgroundView.alpha = alpha profileHeaderView.avatarButton.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha } } // MARK: - ProfileHeaderViewDelegate extension ProfileHeaderViewController: ProfileHeaderViewDelegate { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { guard let user = viewModel.user else { return } let record: ManagedObjectRecord = .init(objectID: user.objectID) Task { try await DataSourceFacade.coordinateToMediaPreviewScene( dependency: self, user: record, previewContext: DataSourceFacade.ImagePreviewContext( imageView: button.avatarImageView, containerView: .profileAvatar(profileHeaderView) ) ) } // end Task } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { guard let user = viewModel.user else { return } let record: ManagedObjectRecord = .init(objectID: user.objectID) Task { try await DataSourceFacade.coordinateToMediaPreviewScene( dependency: self, user: record, previewContext: DataSourceFacade.ImagePreviewContext( imageView: imageView, containerView: .profileBanner(profileHeaderView) ) ) } // end Task } func profileHeaderView( _ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton ) { delegate?.profileHeaderViewController( self, profileHeaderView: profileHeaderView, relationshipButtonDidPressed: button ) } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { delegate?.profileHeaderViewController( self, profileHeaderView: profileHeaderView, metaTextView: metaTextView, metaDidPressed: meta ) } func profileHeaderView( _ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter ) { switch meter { case .post: // do nothing break case .follower: guard let domain = viewModel.user?.domain, let userID = viewModel.user?.id else { return } let followerListViewModel = FollowerListViewModel( context: context, authContext: viewModel.authContext, domain: domain, userID: userID ) _ = coordinator.present( scene: .follower(viewModel: followerListViewModel), from: self, transition: .show ) case .following: guard let domain = viewModel.user?.domain, let userID = viewModel.user?.id else { return } let followingListViewModel = FollowingListViewModel( context: context, authContext: viewModel.authContext, domain: domain, userID: userID ) _ = coordinator.present( scene: .following(viewModel: followingListViewModel), from: self, transition: .show ) } } } // MARK: - MetaTextDelegate extension ProfileHeaderViewController: MetaTextDelegate { func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { switch metaText { case profileHeaderView.bioMetaText: guard viewModel.isEditing else { break } defer { viewModel.profileInfoEditing.note = metaText.backedString } let metaContent = PlaintextMetaContent(string: metaText.backedString) return metaContent default: assertionFailure() } return nil } } // MARK: - PHPickerViewControllerDelegate extension ProfileHeaderViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) guard let result = results.first else { return } ItemProviderLoader.loadImageData(from: result) .sink { [weak self] completion in guard let _ = self else { return } switch completion { case .failure: // TODO: handle error break case .finished: break } } receiveValue: { [weak self] file in guard let self = self else { return } guard let imageData = file?.data else { return } guard let image = UIImage(data: imageData) else { return } self.cropImage(image: image, pickerViewController: picker) } .store(in: &disposeBag) } } // MARK: - UIImagePickerControllerDelegate extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { picker.dismiss(animated: true, completion: nil) guard let image = info[.originalImage] as? UIImage else { return } cropImage(image: image, pickerViewController: picker) } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true, completion: nil) } } // MARK: - UIDocumentPickerDelegate extension ProfileHeaderViewController: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } do { guard url.startAccessingSecurityScopedResource() else { return } defer { url.stopAccessingSecurityScopedResource() } let imageData = try Data(contentsOf: url) guard let image = UIImage(data: imageData) else { return } cropImage(image: image, pickerViewController: controller) } catch { } } } // MARK: - CropViewControllerDelegate extension ProfileHeaderViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { switch currentImageType { case .banner: viewModel.profileInfoEditing.header = image case .avatar: viewModel.profileInfoEditing.avatar = image } cropViewController.dismiss(animated: true, completion: nil) } } // MARK: - TabBarPagerHeader extension ProfileHeaderViewController: TabBarPagerHeader { }