diff --git a/Extensions/CollectionItem+Extensions.swift b/Extensions/CollectionItem+Extensions.swift index 447bc40..df6ea72 100644 --- a/Extensions/CollectionItem+Extensions.swift +++ b/Extensions/CollectionItem+Extensions.swift @@ -25,4 +25,17 @@ extension CollectionItem { return ConversationListCell.self } } + + func estimatedHeight(width: CGFloat, identification: Identification) -> CGFloat { + switch self { + case let .status(status, configuration): + return StatusView.estimatedHeight( + width: width, + identification: identification, + status: status, + configuration: configuration) + default: + return UITableView.automaticDimension + } + } } diff --git a/Extensions/String+Extensions.swift b/Extensions/String+Extensions.swift index 9327d32..c9d3f14 100644 --- a/Extensions/String+Extensions.swift +++ b/Extensions/String+Extensions.swift @@ -4,6 +4,15 @@ import Mastodon import UIKit extension String { + func height(width: CGFloat, font: UIFont) -> CGFloat { + (self as NSString).boundingRect( + with: CGSize(width: width, height: .greatestFiniteMagnitude), + options: .usesLineFragmentOrigin, + attributes: [.font: font], + context: nil) + .height + } + func countEmphasizedAttributedString(count: Int, highlighted: Bool = false) -> NSAttributedString { let countRange = (self as NSString).range(of: String.localizedStringWithFormat("%ld", count)) diff --git a/View Controllers/TableViewController.swift b/View Controllers/TableViewController.swift index 4e0746e..e6ec854 100644 --- a/View Controllers/TableViewController.swift +++ b/View Controllers/TableViewController.swift @@ -110,7 +110,9 @@ class TableViewController: UITableViewController { override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let item = dataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension } - return cellHeightCaches[tableView.frame.width]?[item] ?? UITableView.automaticDimension + return cellHeightCaches[tableView.frame.width]?[item] + ?? item.estimatedHeight(width: tableView.readableContentGuide.layoutFrame.width, + identification: identification) } override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { diff --git a/Views/AttachmentsView.swift b/Views/AttachmentsView.swift index d17a204..e314065 100644 --- a/Views/AttachmentsView.swift +++ b/Views/AttachmentsView.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import Mastodon import UIKit import ViewModels @@ -80,6 +81,20 @@ final class AttachmentsView: UIView { } extension AttachmentsView { + static func estimatedHeight(width: CGFloat, + identification: Identification, + status: Status, + configuration: CollectionItem.StatusConfiguration) -> CGFloat { + let height: CGFloat + if status.displayStatus.mediaAttachments.count == 1, + let aspectRatio = status.mediaAttachments.first?.aspectRatio { + height = width / max(CGFloat(aspectRatio), 16 / 9) + } else { + height = width / (16 / 9) + } + + return height + } var shouldAutoplay: Bool { guard !isHidden, let viewModel = viewModel, viewModel.shouldShowAttachments else { return false } diff --git a/Views/PollOptionButton.swift b/Views/PollOptionButton.swift index bfbf804..2ccf5d6 100644 --- a/Views/PollOptionButton.swift +++ b/Views/PollOptionButton.swift @@ -41,6 +41,12 @@ final class PollOptionButton: UIButton { } } +extension PollOptionButton { + static func estimatedHeight(width: CGFloat, title: String) -> CGFloat { + title.height(width: width, font: .preferredFont(forTextStyle: .callout)) + } +} + private extension PollOptionButton { static let titleEdgeInsets = UIEdgeInsets(top: 0, left: .compactSpacing, bottom: 0, right: .compactSpacing) } diff --git a/Views/PollResultView.swift b/Views/PollResultView.swift index b0610e8..13dd669 100644 --- a/Views/PollResultView.swift +++ b/Views/PollResultView.swift @@ -74,6 +74,14 @@ final class PollResultView: UIView { } } +extension PollResultView { + static func estimatedHeight(width: CGFloat, title: String) -> CGFloat { + title.height(width: width, font: .preferredFont(forTextStyle: .callout)) + + .compactSpacing + + 4 // progress view height + } +} + private extension PollResultView { private static var percentFormatter: NumberFormatter = { let percentageFormatter = NumberFormatter() diff --git a/Views/PollView.swift b/Views/PollView.swift index 5cf56f8..55ad8a8 100644 --- a/Views/PollView.swift +++ b/Views/PollView.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Combine +import Mastodon import UIKit import ViewModels @@ -112,6 +113,34 @@ final class PollView: UIView { } extension PollView { + static func estimatedHeight(width: CGFloat, + identification: Identification, + status: Status, + configuration: CollectionItem.StatusConfiguration) -> CGFloat { + if let poll = status.displayStatus.poll { + var height: CGFloat = 0 + let open = !poll.expired && !poll.voted + + for option in poll.options { + height += open ? PollOptionButton.estimatedHeight(width: width, title: option.title) + : PollResultView.estimatedHeight(width: width, title: option.title) + height += .defaultSpacing + } + + if open { + height += .minimumButtonDimension + .defaultSpacing + } + + height += .minimumButtonDimension / 2 + + return height + } else { + return 0 + } + } +} + +private extension PollView { func initialSetup() { addSubview(stackView) stackView.translatesAutoresizingMaskIntoConstraints = false @@ -146,11 +175,20 @@ extension PollView { bottomStackView.addArrangedSubview(UIView()) + let voteButtonHeightConstraint = voteButton.heightAnchor.constraint(equalToConstant: .minimumButtonDimension) + let refreshButtonHeightConstraint = refreshButton.heightAnchor.constraint( + equalToConstant: .minimumButtonDimension / 2) + + refreshButtonHeightConstraint.priority = .justBelowMax + refreshButtonHeightConstraint.priority = .justBelowMax + NSLayoutConstraint.activate([ stackView.leadingAnchor.constraint(equalTo: leadingAnchor), stackView.topAnchor.constraint(equalTo: topAnchor), stackView.trailingAnchor.constraint(equalTo: trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: bottomAnchor) + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + voteButtonHeightConstraint, + refreshButtonHeightConstraint ]) } } diff --git a/Views/Status/CardView.swift b/Views/Status/CardView.swift index 0b6e9f9..efd95b5 100644 --- a/Views/Status/CardView.swift +++ b/Views/Status/CardView.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Kingfisher +import Mastodon import UIKit import ViewModels @@ -44,6 +45,23 @@ final class CardView: UIView { } } +extension CardView { + static func estimatedHeight(width: CGFloat, + identification: Identification, + status: Status, + configuration: CollectionItem.StatusConfiguration) -> CGFloat { + if status.displayStatus.card != nil { + return round(UIFont.preferredFont(forTextStyle: .headline).lineHeight + + UIFont.preferredFont(forTextStyle: .subheadline).lineHeight + + UIFont.preferredFont(forTextStyle: .footnote).lineHeight + + .defaultSpacing * 2 + + .compactSpacing * 2) + } else { + return 0 + } + } +} + private extension CardView { // swiftlint:disable:next function_body_length func initialSetup() { diff --git a/Views/Status/StatusBodyView.swift b/Views/Status/StatusBodyView.swift index 446733e..368c770 100644 --- a/Views/Status/StatusBodyView.swift +++ b/Views/Status/StatusBodyView.swift @@ -1,5 +1,6 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Mastodon import UIKit import ViewModels @@ -49,7 +50,7 @@ final class StatusBodyView: UIView { attachmentsView.isHidden = viewModel.attachmentViewModels.isEmpty attachmentsView.viewModel = viewModel - pollView.isHidden = viewModel.pollOptions.isEmpty + pollView.isHidden = viewModel.pollOptions.isEmpty || !viewModel.shouldShowContent pollView.viewModel = viewModel cardView.viewModel = viewModel.cardViewModel @@ -69,6 +70,64 @@ final class StatusBodyView: UIView { } } +extension StatusBodyView { + static func estimatedHeight(width: CGFloat, + identification: Identification, + status: Status, + configuration: CollectionItem.StatusConfiguration) -> CGFloat { + let contentFont = UIFont.preferredFont(forTextStyle: configuration.isContextParent ? .title3 : .callout) + var height: CGFloat = 0 + + var contentHeight = status.displayStatus.content.attributed.string.height( + width: width, + font: contentFont) + + if status.displayStatus.card != nil { + contentHeight += .compactSpacing + contentHeight += CardView.estimatedHeight( + width: width, + identification: identification, + status: status, + configuration: configuration) + } + + if status.displayStatus.poll != nil { + contentHeight += .defaultSpacing + contentHeight += PollView.estimatedHeight( + width: width, + identification: identification, + status: status, + configuration: configuration) + } + + if status.displayStatus.spoilerText.isEmpty { + height += contentHeight + } else { + height += status.displayStatus.spoilerText.height(width: width, font: contentFont) + height += .compactSpacing + height += NSLocalizedString("status.show-more", comment: "").height( + width: width, + font: .preferredFont(forTextStyle: .headline)) + + if configuration.showContentToggled && !identification.identity.preferences.readingExpandSpoilers { + height += .compactSpacing + height += contentHeight + } + } + + if !status.displayStatus.mediaAttachments.isEmpty { + height += .compactSpacing + height += AttachmentsView.estimatedHeight( + width: width, + identification: identification, + status: status, + configuration: configuration) + } + + return height + } +} + extension StatusBodyView: UITextViewDelegate { func textView( _ textView: UITextView, diff --git a/Views/Status/StatusView.swift b/Views/Status/StatusView.swift index 54575b5..c8fa3cb 100644 --- a/Views/Status/StatusView.swift +++ b/Views/Status/StatusView.swift @@ -2,6 +2,7 @@ // swiftlint:disable file_length import Kingfisher +import Mastodon import UIKit import ViewModels @@ -56,6 +57,36 @@ final class StatusView: UIView { } } +extension StatusView { + static func estimatedHeight(width: CGFloat, + identification: Identification, + status: Status, + configuration: CollectionItem.StatusConfiguration) -> CGFloat { + var height = CGFloat.defaultSpacing * 2 + let bodyWidth = width - .defaultSpacing - .avatarDimension + + if status.reblog != nil || configuration.isPinned { + height += UIFont.preferredFont(forTextStyle: .caption1).lineHeight + .compactSpacing + } + + if configuration.isContextParent { + height += .avatarDimension + .minimumButtonDimension * 2.5 + .hairline * 2 + .compactSpacing * 4 + } else { + height += UIFont.preferredFont(forTextStyle: .headline).lineHeight + + .compactSpacing + .minimumButtonDimension / 2 + } + + height += StatusBodyView.estimatedHeight( + width: bodyWidth, + identification: identification, + status: status, + configuration: configuration) + + .compactSpacing + + return height + } +} + extension StatusView: UIContentView { var configuration: UIContentConfiguration { get { statusConfiguration } @@ -142,6 +173,7 @@ private extension StatusView { mainStackView.addArrangedSubview(nameAccountContainerStackView) mainStackView.addArrangedSubview(bodyView) + bodyView.tag = 666 contextParentTimeLabel.font = .preferredFont(forTextStyle: .footnote) contextParentTimeLabel.adjustsFontForContentSizeCategory = true @@ -264,7 +296,10 @@ private extension StatusView { avatarButton.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), avatarButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), - avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor) + avatarButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + contextParentTimeApplicationStackView.heightAnchor.constraint( + greaterThanOrEqualToConstant: .minimumButtonDimension / 2), + interactionsStackView.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension) ]) } @@ -374,7 +409,8 @@ private extension StatusView { if isContextParent { button.heightAnchor.constraint(equalToConstant: .minimumButtonDimension).isActive = true } else { - button.heightAnchor.constraint(greaterThanOrEqualToConstant: 0).isActive = true + button.heightAnchor.constraint( + greaterThanOrEqualToConstant: .minimumButtonDimension / 2).isActive = true } }