From d85f74f365e4722e6000fb0667011b34c74c6a52 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 6 May 2023 13:52:47 -0400 Subject: [PATCH] Fix crash due to layout loop when laying out fields on certain profiles Closes #378 Also make field layout more consistent, and tweak appearance --- Tusker.xcodeproj/project.pbxproj | 8 + .../Profile/ProfileViewController.swift | 3 +- Tusker/Views/EmojiLabel.swift | 2 +- .../ProfileFieldValueView.swift | 212 ++++++++++++ .../ProfileFieldVerificationView.swift | 68 ++++ .../Profile Header/ProfileFieldsView.swift | 319 ++++++------------ .../Profile Header/ProfileHeaderView.xib | 12 +- 7 files changed, 401 insertions(+), 223 deletions(-) create mode 100644 Tusker/Views/Profile Header/ProfileFieldValueView.swift create mode 100644 Tusker/Views/Profile Header/ProfileFieldVerificationView.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 2e066ae4..80c40e6c 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -118,6 +118,8 @@ D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; }; D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */; }; D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; }; + D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */; }; + D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */; }; D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; }; D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; }; D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; }; @@ -511,6 +513,8 @@ D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = ""; }; D646C957213B367000269FB5 /* LargeImageShrinkAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageShrinkAnimationController.swift; sourceTree = ""; }; D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = ""; }; + D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldValueView.swift; sourceTree = ""; }; + D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldVerificationView.swift; sourceTree = ""; }; D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = ""; }; D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; }; @@ -1136,6 +1140,8 @@ D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */, D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */, D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */, + D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */, + D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */, D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */, D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */, ); @@ -2032,6 +2038,7 @@ D67B506D250B291200FAECFB /* BlurHashDecode.swift in Sources */, D68A76EC295369A8001DA1B3 /* AboutView.swift in Sources */, 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, + D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */, D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */, D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, @@ -2118,6 +2125,7 @@ D663626C21361C6700C9CBA2 /* Account+Preferences.swift in Sources */, D67895C0246870DE00D4CD9E /* LocalAccountAvatarView.swift in Sources */, D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */, + D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */, D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index 2579372a..7140478c 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -206,10 +206,11 @@ class ProfileViewController: UIViewController, StateRestorableViewController { headerView.layer.zPosition = 100 view.addSubview(headerView) let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y - // TODO: use safe area layout guide instead of manually adjusting this? let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top + let headerBottomOffset = oldHeaderCell.convert(CGPoint(x: 0, y: oldHeaderCell.bounds.maxY), to: view).y// - view.safeAreaInsets.top NSLayoutConstraint.activate([ headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset), + headerView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: headerBottomOffset), headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) diff --git a/Tusker/Views/EmojiLabel.swift b/Tusker/Views/EmojiLabel.swift index 2f13e31e..fa199856 100644 --- a/Tusker/Views/EmojiLabel.swift +++ b/Tusker/Views/EmojiLabel.swift @@ -21,7 +21,7 @@ class EmojiLabel: UILabel, BaseEmojiLabel { func setEmojis(_ emojis: [Emoji], identifier: String) { guard emojis.count > 0, let attributedText = attributedText else { return } - replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in + replaceEmojis(in: attributedText, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in guard let self = self, self.emojiIdentifier == identifier else { return } self.hasEmojis = didReplaceEmojis self.attributedText = newAttributedText diff --git a/Tusker/Views/Profile Header/ProfileFieldValueView.swift b/Tusker/Views/Profile Header/ProfileFieldValueView.swift new file mode 100644 index 00000000..429eeade --- /dev/null +++ b/Tusker/Views/Profile Header/ProfileFieldValueView.swift @@ -0,0 +1,212 @@ +// +// ProfileFieldValueView.swift +// Tusker +// +// Created by Shadowfacts on 5/6/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import SwiftUI +import SafariServices + +class ProfileFieldValueView: UIView { + weak var navigationDelegate: TuskerNavigationDelegate? + + private static let converter: HTMLConverter = { + var converter = HTMLConverter() + converter.font = .preferredFont(forTextStyle: .body) + converter.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) + return converter + }() + + private let account: AccountMO + private let field: Account.Field + private var link: (String, URL)? + + private let label = EmojiLabel() + private var iconView: UIView? + + private var currentTargetedPreview: UITargetedPreview? + + init(field: Account.Field, account: AccountMO) { + self.account = account + self.field = field + + super.init(frame: .zero) + + let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) + + var range = NSRange(location: 0, length: 0) + if converted.length != 0, + let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL { + link = (converted.attributedSubstring(from: range).string, url) + label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped))) + label.addInteraction(UIContextMenuInteraction(delegate: self)) + label.isUserInteractionEnabled = true + + converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in + guard value != nil else { return } + converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) + // the .link attribute in a UILabel always makes the color blue >.> + converted.removeAttribute(.link, range: range) + } + } + + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.attributedText = converted + label.setEmojis(account.emojis, identifier: account.id) + label.setContentCompressionResistancePriority(.required, for: .vertical) + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + + let labelTrailingConstraint: NSLayoutConstraint + + if field.verifiedAt != nil { + var config = UIButton.Configuration.plain() + config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium) + config.image = UIImage(systemName: "checkmark") + config.baseForegroundColor = .systemGreen + let icon = UIButton(configuration: config) + self.iconView = icon + icon.translatesAutoresizingMaskIntoConstraints = false + icon.setContentHuggingPriority(.defaultHigh, for: .horizontal) + icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside) + icon.isPointerInteractionEnabled = true + icon.accessibilityLabel = "Verified link" + addSubview(icon) + labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor) + NSLayoutConstraint.activate([ + icon.centerYAnchor.constraint(equalTo: label.centerYAnchor), + icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + ]) + } else { + labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor) + } + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor), + labelTrailingConstraint, + label.topAnchor.constraint(equalTo: topAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + var size = label.sizeThatFits(size) + if let iconView { + size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width + } + return size + } + + func setTextAlignment(_ alignment: NSTextAlignment) { + label.textAlignment = alignment + } + + func getHashtagOrURL() -> (Hashtag?, URL)? { + guard let (text, url) = link else { + return nil + } + if text.starts(with: "#") { + return (Hashtag(name: String(text.dropFirst()), url: url), url) + } else { + return (nil, url) + } + } + + @objc private func linkTapped() { + guard let (hashtag, url) = getHashtagOrURL() else { + return + } + if let hashtag { + navigationDelegate?.selected(tag: hashtag) + } else { + navigationDelegate?.selected(url: url) + } + } + + @objc private func verifiedIconTapped() { + guard let navigationDelegate else { + return + } + let view = ProfileFieldVerificationView( + acct: account.acct, + verifiedAt: field.verifiedAt!, + linkText: label.text ?? "", + navigationDelegate: navigationDelegate + ) + let host = UIHostingController(rootView: view) + let toPresent: UIViewController + if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact { + toPresent = UINavigationController(rootViewController: host) + toPresent.modalPresentationStyle = .pageSheet + let sheetPresentationController = toPresent.sheetPresentationController! + sheetPresentationController.detents = [ + .medium() + ] + } else { + host.modalPresentationStyle = .popover + let popoverPresentationController = host.popoverPresentationController! + popoverPresentationController.sourceView = iconView + host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity)) + toPresent = host + } + navigationDelegate.present(toPresent, animated: true) + } +} + +extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider { + var toastableViewController: ToastableViewController? { + navigationDelegate + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + guard let (hashtag, url) = getHashtagOrURL(), + let navigationDelegate else { + return nil + } + if let hashtag { + return UIContextMenuConfiguration { + HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController) + } actionProvider: { _ in + UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self))) + } + } else { + return UIContextMenuConfiguration { + let vc = SFSafariViewController(url: url) + vc.preferredControlTintColor = Preferences.shared.accentColor.color + return vc + } actionProvider: { _ in + UIMenu(children: self.actionsForURL(url, source: .view(self))) + } + } + } + + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { + var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0) + // the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account + rect.origin.x = 0 + rect.origin.y = (bounds.height - rect.height) / 2 + let parameters = UIPreviewParameters(textLineRects: [rect as NSValue]) + let preview = UITargetedPreview(view: label, parameters: parameters) + currentTargetedPreview = preview + return preview + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { + return currentTargetedPreview + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!) + } +} diff --git a/Tusker/Views/Profile Header/ProfileFieldVerificationView.swift b/Tusker/Views/Profile Header/ProfileFieldVerificationView.swift new file mode 100644 index 00000000..210e26f8 --- /dev/null +++ b/Tusker/Views/Profile Header/ProfileFieldVerificationView.swift @@ -0,0 +1,68 @@ +// +// ProfileFieldVerificationView.swift +// Tusker +// +// Created by Shadowfacts on 5/6/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import SwiftUI + +@MainActor +struct ProfileFieldVerificationView: View { + let acct: String + let verifiedAt: Date + let linkText: String + let navigationDelegate: TuskerNavigationDelegate + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + firstLine + secondLine + } + .padding() + .navigationTitle(Text("Verified Link")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + navigationDelegate.dismiss(animated: true) + } + } + } + .environment(\.openURL, OpenURLAction(handler: { url in + // dismiss the sheet/popover first + navigationDelegate.dismiss(animated: true) { + navigationDelegate.selected(url: url) + } + return .handled + })) + } + + private var firstLine: Text { + var attrStr: AttributedString = "This link has been verified by your instance, " + var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!) + instance.font = .body.bold() + attrStr += instance + attrStr += "." + return Text(attrStr) + } + + private var secondLine: Text { + var attrStr: AttributedString = "The page at " + var linkStr: AttributedString + if linkText.count > 43 { + linkStr = AttributedString(linkText.prefix(40) + "…") + } else { + linkStr = AttributedString(linkText) + } + linkStr.link = URL(string: linkText) + attrStr += linkStr + attrStr += " was confirmed to link back to " + var acctStr = AttributedString("@\(acct)") + acctStr.font = .body.bold() + attrStr += acctStr + attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).") + return Text(attrStr) + } +} diff --git a/Tusker/Views/Profile Header/ProfileFieldsView.swift b/Tusker/Views/Profile Header/ProfileFieldsView.swift index 38f858ff..7e034134 100644 --- a/Tusker/Views/Profile Header/ProfileFieldsView.swift +++ b/Tusker/Views/Profile Header/ProfileFieldsView.swift @@ -8,7 +8,6 @@ import UIKit import Pachyderm -import SwiftUI class ProfileFieldsView: UIView { @@ -16,9 +15,19 @@ class ProfileFieldsView: UIView { private var fields = [Account.Field]() - private let stack = UIStackView() - private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = [] + private var fieldViews: [(EmojiLabel, ProfileFieldValueView, UIView)] = [] private var fieldConstraints: [NSLayoutConstraint] = [] + private lazy var dividerLayoutGuide: UILayoutGuide = { + let guide = UILayoutGuide() + addLayoutGuide(guide) + guide.widthAnchor.constraint(equalToConstant: 8).isActive = true + let centerDividerConstraint = guide.centerXAnchor.constraint(equalTo: centerXAnchor) + centerDividerConstraint.priority = .defaultHigh + centerDividerConstraint.isActive = true + return guide + }() + private var dividerXConstraint: NSLayoutConstraint? + private var boundsObservation: NSKeyValueObservation? private var isUsingSingleColumn: Bool = false private var needsSingleColumn: Bool { @@ -43,16 +52,9 @@ class ProfileFieldsView: UIView { } private func commonInit() { - stack.axis = .vertical - stack.alignment = .fill - stack.translatesAutoresizingMaskIntoConstraints = false - addSubview(stack) - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: leadingAnchor), - stack.trailingAnchor.constraint(equalTo: trailingAnchor), - stack.topAnchor.constraint(equalTo: topAnchor), - stack.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) + boundsObservation = observe(\.bounds, changeHandler: { [unowned self] _, _ in + self.setNeedsUpdateConstraints() + }) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -70,27 +72,53 @@ class ProfileFieldsView: UIView { } fields = account.fields - for (name, value) in fieldViews { + for (name, value, fieldContainer) in fieldViews { name.removeFromSuperview() value.removeFromSuperview() + fieldContainer.removeFromSuperview() } fieldViews = [] - for field in account.fields { + for (index, field) in account.fields.enumerated() { let nameLabel = EmojiLabel() nameLabel.text = field.name nameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)! nameLabel.adjustsFontForContentSizeCategory = true nameLabel.numberOfLines = 0 nameLabel.lineBreakMode = .byWordWrapping + nameLabel.showsExpansionTextWhenTruncated = true nameLabel.setEmojis(account.emojis, identifier: account.id) - nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + nameLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) let valueView = ProfileFieldValueView(field: field, account: account) valueView.navigationDelegate = delegate valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - fieldViews.append((nameLabel, valueView)) + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(nameLabel) + valueView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(valueView) + + if index % 2 == 0 { + container.backgroundColor = .secondarySystemFill + } else { + container.backgroundColor = .quaternarySystemFill + } + if index == 0 || index == fields.count - 1 { + if fields.count > 1 && index == 0 { + container.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + } else if fields.count > 1 && index == fields.count - 1 { + container.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + } + container.layer.cornerRadius = 8 + container.layer.cornerCurve = .continuous + } + + fieldViews.append((nameLabel, valueView, container)) } configureFields() @@ -106,222 +134,83 @@ class ProfileFieldsView: UIView { NSLayoutConstraint.deactivate(fieldConstraints) fieldConstraints = [] - stack.arrangedSubviews.forEach { $0.removeFromSuperview() } + var prevContainer: UIView? - if needsSingleColumn { - stack.spacing = 4 - var isFirst = true - for (name, value) in fieldViews { - if isFirst { - isFirst = false - } else { - let spacer = UIView() - // don't need any height, since there's 4pts of padding on either side - spacer.heightAnchor.constraint(equalToConstant: 0).isActive = true - stack.addArrangedSubview(spacer) - } - name.textAlignment = .natural - stack.addArrangedSubview(name) - value.setTextAlignment(.natural) - stack.addArrangedSubview(value) - } - } else { - stack.spacing = 8 - - let dividerLayoutGuide = UILayoutGuide() - addLayoutGuide(dividerLayoutGuide) + for (name, value, container) in fieldViews { fieldConstraints.append(contentsOf: [ - dividerLayoutGuide.widthAnchor.constraint(equalToConstant: 8), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + container.trailingAnchor.constraint(equalTo: trailingAnchor), ]) - for (name, value) in fieldViews { - name.textAlignment = .right - name.translatesAutoresizingMaskIntoConstraints = false + if needsSingleColumn { + name.textAlignment = .natural + value.setTextAlignment(.natural) - value.setTextAlignment(.left) - value.translatesAutoresizingMaskIntoConstraints = false - - let fieldContainer = UIView() - fieldContainer.addSubview(name) - fieldContainer.addSubview(value) - stack.addArrangedSubview(fieldContainer) fieldConstraints.append(contentsOf: [ - name.leadingAnchor.constraint(equalTo: fieldContainer.leadingAnchor), + name.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + name.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4), + name.topAnchor.constraint(equalTo: container.topAnchor, constant: 4), + + value.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4), + value.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4), + value.topAnchor.constraint(equalTo: name.bottomAnchor), + value.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4), + ]) + } else { + name.textAlignment = .right + value.setTextAlignment(.left) + + fieldConstraints.append(contentsOf: [ + container.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), + + name.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 4), name.trailingAnchor.constraint(equalTo: dividerLayoutGuide.leadingAnchor), - name.topAnchor.constraint(equalTo: fieldContainer.topAnchor), - name.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor), + name.topAnchor.constraint(equalTo: container.topAnchor, constant: 4), + name.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4), value.leadingAnchor.constraint(equalTo: dividerLayoutGuide.trailingAnchor), - value.trailingAnchor.constraint(equalTo: fieldContainer.trailingAnchor), - value.topAnchor.constraint(equalTo: fieldContainer.topAnchor), - value.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor), - - name.widthAnchor.constraint(greaterThanOrEqualTo: value.widthAnchor, multiplier: 0.5), - name.widthAnchor.constraint(lessThanOrEqualTo: value.widthAnchor, multiplier: 2), + value.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4), + value.topAnchor.constraint(equalTo: container.topAnchor, constant: 4), + value.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4), ]) } + + let containerTopConstraint = container.topAnchor.constraint(equalTo: prevContainer?.bottomAnchor ?? topAnchor) + fieldConstraints.append(containerTopConstraint) + prevContainer = container + } + + if let prevContainer { + let lastContainerBottomConstraint = prevContainer.bottomAnchor.constraint(equalTo: bottomAnchor) + fieldConstraints.append(lastContainerBottomConstraint) } NSLayoutConstraint.activate(fieldConstraints) } -} - -private class ProfileFieldValueView: UIView { - weak var navigationDelegate: TuskerNavigationDelegate? { - didSet { - textView.navigationDelegate = navigationDelegate - } - } - - private let account: AccountMO - private let field: Account.Field - - private let textView = ContentTextView() - private var iconView: UIView? - - init(field: Account.Field, account: AccountMO) { - self.account = account - self.field = field - - super.init(frame: .zero) - - textView.isSelectable = false - textView.backgroundColor = .clear - textView.defaultFont = .preferredFont(forTextStyle: .body) - textView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) - textView.adjustsFontForContentSizeCategory = true - textView.setTextFromHtml(field.value) - textView.setEmojis(account.emojis, identifier: account.id) - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textView.translatesAutoresizingMaskIntoConstraints = false - addSubview(textView) - - let textViewTrailingConstraint: NSLayoutConstraint - - if field.verifiedAt != nil { - var config = UIButton.Configuration.plain() - config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium) - config.image = UIImage(systemName: "checkmark") - config.baseForegroundColor = .systemGreen - let icon = UIButton(configuration: config) - self.iconView = icon - icon.translatesAutoresizingMaskIntoConstraints = false - icon.setContentHuggingPriority(.defaultHigh, for: .horizontal) - icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside) - icon.isPointerInteractionEnabled = true - icon.accessibilityLabel = "Verified link" - addSubview(icon) - textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor) - NSLayoutConstraint.activate([ - icon.lastBaselineAnchor.constraint(equalTo: textView.lastBaselineAnchor), - icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), - ]) - } else { - textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor) - } - - NSLayoutConstraint.activate([ - textView.leadingAnchor.constraint(equalTo: leadingAnchor), - textViewTrailingConstraint, - textView.topAnchor.constraint(equalTo: topAnchor), - textView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func setTextAlignment(_ alignment: NSTextAlignment) { - textView.textAlignment = alignment - } - - @objc private func verifiedIconTapped() { - guard let navigationDelegate else { - return - } - let view = ProfileFieldVerificationView( - acct: account.acct, - verifiedAt: field.verifiedAt!, - linkText: textView.text, - navigationDelegate: navigationDelegate - ) - let host = UIHostingController(rootView: view) - let toPresent: UIViewController - if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact { - toPresent = UINavigationController(rootViewController: host) - toPresent.modalPresentationStyle = .pageSheet - let sheetPresentationController = toPresent.sheetPresentationController! - sheetPresentationController.detents = [ - .medium() - ] - } else { - host.modalPresentationStyle = .popover - let popoverPresentationController = host.popoverPresentationController! - popoverPresentationController.sourceView = iconView - host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity)) - toPresent = host - } - navigationDelegate.present(toPresent, animated: true) - } -} - -@MainActor -private struct ProfileFieldVerificationView: View { - let acct: String - let verifiedAt: Date - let linkText: String - let navigationDelegate: TuskerNavigationDelegate - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - firstLine - secondLine - } - .padding() - .navigationTitle(Text("Verified Link")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - navigationDelegate.dismiss(animated: true) - } + override func updateConstraints() { + if !needsSingleColumn, + !fieldViews.isEmpty { + let maxNameWidth = fieldViews.map { + $0.0.sizeThatFits(UIView.layoutFittingCompressedSize).width + }.max()! + let maxValueWidth = fieldViews.map { + $0.1.sizeThatFits(UIView.layoutFittingCompressedSize).width + }.max()! + + let defaultWidth = (bounds.width - 8) / 2 + + dividerXConstraint?.isActive = false + if maxNameWidth > defaultWidth && maxValueWidth < defaultWidth { + dividerXConstraint = dividerLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor, constant: min(maxNameWidth, bounds.width * 2 / 3)) + dividerXConstraint!.isActive = true + } else if maxNameWidth < defaultWidth && maxValueWidth > defaultWidth { + dividerXConstraint = dividerLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -min(maxValueWidth, bounds.width * 2 / 3)) + dividerXConstraint!.isActive = true } } - .environment(\.openURL, OpenURLAction(handler: { url in - // dismiss the sheet/popover first - navigationDelegate.dismiss(animated: true) { - navigationDelegate.selected(url: url) - } - return .handled - })) + + super.updateConstraints() } - private var firstLine: Text { - var attrStr: AttributedString = "This link has been verified by your instance, " - var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!) - instance.font = .body.bold() - attrStr += instance - attrStr += "." - return Text(attrStr) - } - - private var secondLine: Text { - var attrStr: AttributedString = "The page at " - var linkStr: AttributedString - if linkText.count > 43 { - linkStr = AttributedString(linkText.prefix(40) + "…") - } else { - linkStr = AttributedString(linkText) - } - linkStr.link = URL(string: linkText) - attrStr += linkStr - attrStr += " was confirmed to link back to " - var acctStr = AttributedString("@\(acct)") - acctStr.font = .body.bold() - attrStr += acctStr - attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).") - return Text(attrStr) - } } diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.xib b/Tusker/Views/Profile Header/ProfileHeaderView.xib index 8cbd636a..4675203a 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.xib +++ b/Tusker/Views/Profile Header/ProfileHeaderView.xib @@ -1,9 +1,9 @@ - + - + @@ -62,7 +62,7 @@ - + - + @@ -155,7 +155,7 @@ - +