Make Timeline text layout like current apps

This commit is contained in:
Maurice Parker 2020-07-16 19:36:20 -05:00
parent ba7fcd70c9
commit 8f7b8160a1
5 changed files with 322 additions and 75 deletions

View File

@ -18,9 +18,13 @@ enum TimelineItemStatus {
struct TimelineItem: Identifiable {
var article: Article
var truncatedTitle: String
var truncatedSummary: String
init(article: Article) {
self.article = article
self.truncatedTitle = ArticleStringFormatter.truncatedTitle(article)
self.truncatedSummary = ArticleStringFormatter.truncatedSummary(article)
updateStatus()
}
@ -50,4 +54,45 @@ struct TimelineItem: Identifiable {
}
}
func numberOfTitleLines(width: CGFloat) -> Int {
guard !truncatedTitle.isEmpty else { return 0 }
#if os(macOS)
let descriptor = NSFont.preferredFont(forTextStyle: .body).fontDescriptor.withSymbolicTraits(.bold)
guard let font = NSFont(descriptor: descriptor, size: 0) else { return 0 }
#else
guard let descriptor = UIFont.preferredFont(forTextStyle: .body).fontDescriptor.withSymbolicTraits(.traitBold) else { return 0 }
let font = UIFont(descriptor: descriptor, size: 0)
#endif
let lines = Int(AppDefaults.shared.timelineNumberOfLines)
let sizeInfo = TimelineTextSizer.size(for: truncatedTitle, font: font, numberOfLines: lines, width: adjustedWidth(width))
return sizeInfo.numberOfLinesUsed
}
func numberOfSummaryLines(width: CGFloat, titleLines: Int) -> Int {
guard !truncatedSummary.isEmpty else { return 0 }
let remainingLines = Int(AppDefaults.shared.timelineNumberOfLines) - titleLines
guard remainingLines > 0 else { return 0 }
#if os(macOS)
let font = NSFont.preferredFont(forTextStyle: .body)
#else
let font = UIFont.preferredFont(forTextStyle: .body)
#endif
let sizeInfo = TimelineTextSizer.size(for: truncatedSummary, font: font, numberOfLines: remainingLines, width: adjustedWidth(width))
return sizeInfo.numberOfLinesUsed
}
}
private extension TimelineItem {
// This clearly isn't correct yet, but it gets us close enough for now. -Maurice
func adjustedWidth(_ width: CGFloat) -> Int {
return Int(width - CGFloat(AppDefaults.shared.timelineIconDimensions + 64))
}
}

View File

@ -13,6 +13,7 @@ struct TimelineItemView: View {
@EnvironmentObject var defaults: AppDefaults
@StateObject var articleIconImageLoader = ArticleIconImageLoader()
var width: CGFloat
var timelineItem: TimelineItem
#if os(macOS)
@ -23,33 +24,42 @@ struct TimelineItemView: View {
#endif
var body: some View {
VStack {
HStack(alignment: .top) {
TimelineItemStatusView(status: timelineItem.status)
if let image = articleIconImageLoader.image {
IconImageView(iconImage: image)
.frame(width: CGFloat(defaults.timelineIconDimensions), height: CGFloat(defaults.timelineIconDimensions), alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
}
VStack {
Text(verbatim: timelineItem.article.title ?? "N/A")
HStack(alignment: .top) {
TimelineItemStatusView(status: timelineItem.status)
if let image = articleIconImageLoader.image {
IconImageView(iconImage: image)
.frame(width: CGFloat(defaults.timelineIconDimensions), height: CGFloat(defaults.timelineIconDimensions), alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/)
}
VStack {
let titleLines = timelineItem.numberOfTitleLines(width: width)
if titleLines > 0 {
Text(verbatim: timelineItem.truncatedTitle)
.fontWeight(.semibold)
.lineLimit(Int(defaults.timelineNumberOfLines))
.lineLimit(titleLines)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 4)
.fixedSize(horizontal: false, vertical: true)
}
let summaryLines = timelineItem.numberOfSummaryLines(width: width, titleLines: titleLines)
if summaryLines > 0 {
Text(verbatim: timelineItem.truncatedSummary)
.lineLimit(summaryLines)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 4)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Text(verbatim: timelineItem.byline)
.lineLimit(1)
.truncationMode(.tail)
.font(.footnote)
.foregroundColor(.secondary)
Spacer()
HStack {
Text(verbatim: timelineItem.byline)
.lineLimit(1)
.truncationMode(.tail)
.font(.footnote)
.foregroundColor(.secondary)
Spacer()
Text(verbatim: timelineItem.dateTimeString)
.lineLimit(1)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.trailing, 4)
}
Text(verbatim: timelineItem.dateTimeString)
.lineLimit(1)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.trailing, 4)
}
}
}
@ -59,16 +69,3 @@ struct TimelineItemView: View {
}
}
}
struct TimelineItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
TimelineItemView(timelineItem: TimelineItem(article: PreviewArticles.basicRead))
.frame(maxWidth: 250)
TimelineItemView(timelineItem: TimelineItem(article: PreviewArticles.basicUnread))
.frame(maxWidth: 250)
TimelineItemView(timelineItem: TimelineItem(article: PreviewArticles.basicStarred))
.frame(maxWidth: 250)
}
}
}

View File

@ -0,0 +1,197 @@
//
// MultilineUILabelSizer.swift
// NetNewsWire
//
// Created by Maurice Parker on 7/16/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
#if os(macOS)
import AppKit
typealias RSFont = NSFont
#else
import UIKit
typealias RSFont = UIFont
#endif
// Get the height of an NSTextField given a string, font, and width.
// Uses a cache. Avoids actually measuring text as much as possible.
// Main thread only.
typealias WidthHeightCache = [Int: Int] // width: height
private struct TextSizerSpecifier: Hashable {
let numberOfLines: Int
let font: RSFont
}
struct TextSizeInfo {
let size: CGSize // Integral size (ceiled)
let numberOfLinesUsed: Int // A two-line text field may only use one line, for instance. This would equal 1, then.
}
final class TimelineTextSizer {
private let numberOfLines: Int
private let font: RSFont
private let singleLineHeightEstimate: Int
private let doubleLineHeightEstimate: Int
private var cache = [String: WidthHeightCache]() // Each string has a cache.
private static var sizers = [TextSizerSpecifier: TimelineTextSizer]()
private init(numberOfLines: Int, font: RSFont) {
self.numberOfLines = numberOfLines
self.font = font
self.singleLineHeightEstimate = TimelineTextSizer.calculateHeight("AqLjJ0/y", 200, font)
self.doubleLineHeightEstimate = TimelineTextSizer.calculateHeight("AqLjJ0/y\nAqLjJ0/y", 200, font)
}
static func size(for string: String, font: RSFont, numberOfLines: Int, width: Int) -> TextSizeInfo {
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
}
static func emptyCache() {
sizers = [TextSizerSpecifier: TimelineTextSizer]()
}
}
// MARK: - Private
private extension TimelineTextSizer {
static func sizer(numberOfLines: Int, font: RSFont) -> TimelineTextSizer {
let specifier = TextSizerSpecifier(numberOfLines: numberOfLines, font: font)
if let cachedSizer = sizers[specifier] {
return cachedSizer
}
let newSizer = TimelineTextSizer(numberOfLines: numberOfLines, font: font)
sizers[specifier] = newSizer
return newSizer
}
func sizeInfo(for string: String, width: Int) -> TextSizeInfo {
let textFieldHeight = height(for: string, width: width)
let numberOfLinesUsed = numberOfLines(for: textFieldHeight)
let size = CGSize(width: width, height: textFieldHeight)
let sizeInfo = TextSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
return sizeInfo
}
func height(for string: String, width: Int) -> Int {
if cache[string] == nil {
cache[string] = WidthHeightCache()
}
if let height = cache[string]![width] {
return height
}
if let height = heightConsideringNeighbors(cache[string]!, width) {
return height
}
var height = TimelineTextSizer.calculateHeight(string, width, font)
if numberOfLines != 0 {
let maxHeight = singleLineHeightEstimate * numberOfLines
if height > maxHeight {
height = maxHeight
}
}
cache[string]![width] = height
return height
}
static func calculateHeight(_ string: String, _ width: Int, _ font: RSFont) -> Int {
let height = string.height(withConstrainedWidth: CGFloat(width), font: font)
return Int(ceil(height))
}
func numberOfLines(for height: Int) -> Int {
// Well have to see if this really works reliably.
let averageHeight = CGFloat(doubleLineHeightEstimate) / 2.0
let lines = Int(round(CGFloat(height) / averageHeight))
return lines
}
func heightIsProbablySingleLineHeight(_ height: Int) -> Bool {
return heightIsProbablyEqualToEstimate(height, singleLineHeightEstimate)
}
func heightIsProbablyDoubleLineHeight(_ height: Int) -> Bool {
return heightIsProbablyEqualToEstimate(height, doubleLineHeightEstimate)
}
func heightIsProbablyEqualToEstimate(_ height: Int, _ estimate: Int) -> Bool {
let slop = 4
let minimum = estimate - slop
let maximum = estimate + slop
return height >= minimum && height <= maximum
}
func heightConsideringNeighbors(_ heightCache: WidthHeightCache, _ width: Int) -> Int? {
// Given width, if the height at width - something and width + something is equal,
// then that height must be correct for the given width.
// Also:
// If a narrower neighbors height is single line height, then this wider width must also be single-line height.
// If a wider neighbors height is double line height, and numberOfLines == 2, then this narrower width must able be double-line height.
var smallNeighbor = (width: 0, height: 0)
var largeNeighbor = (width: 0, height: 0)
for (oneWidth, oneHeight) in heightCache {
if oneWidth < width && heightIsProbablySingleLineHeight(oneHeight) {
return oneHeight
}
if numberOfLines == 2 && oneWidth > width && heightIsProbablyDoubleLineHeight(oneHeight) {
return oneHeight
}
if oneWidth < width && (oneWidth > smallNeighbor.width || smallNeighbor.width == 0) {
smallNeighbor = (oneWidth, oneHeight)
}
else if oneWidth > width && (oneWidth < largeNeighbor.width || largeNeighbor.width == 0) {
largeNeighbor = (oneWidth, oneHeight)
}
if smallNeighbor.width != 0 && smallNeighbor.height == largeNeighbor.height {
return smallNeighbor.height
}
}
return nil
}
}
extension String {
func height(withConstrainedWidth width: CGFloat, font: RSFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [NSAttributedString.Key.font: font], context: nil)
return ceil(boundingBox.height)
}
}

View File

@ -14,50 +14,52 @@ struct TimelineView: View {
@State var navigate = true
@ViewBuilder var body: some View {
#if os(macOS)
VStack {
HStack {
TimelineSortOrderView()
Spacer()
Button (action: {
withAnimation {
timelineModel.toggleReadFilter()
GeometryReader { proxy in
#if os(macOS)
VStack {
HStack {
TimelineSortOrderView()
Spacer()
Button (action: {
withAnimation {
timelineModel.toggleReadFilter()
}
}, label: {
if timelineModel.isReadFiltered ?? false {
AppAssets.filterActiveImage
} else {
AppAssets.filterInactiveImage
}
})
.hidden(timelineModel.isReadFiltered == nil)
.padding(.top, 8).padding(.trailing)
.buttonStyle(PlainButtonStyle())
.help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles")
}
ZStack {
NavigationLink(destination: ArticleContainerView(), isActive: $navigate) {
EmptyView()
}.hidden()
List(timelineModel.timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in
TimelineItemView(width: proxy.size.width, timelineItem: timelineItem)
}
}, label: {
if timelineModel.isReadFiltered ?? false {
AppAssets.filterActiveImage
} else {
AppAssets.filterInactiveImage
}
})
.hidden(timelineModel.isReadFiltered == nil)
.padding(.top, 8).padding(.trailing)
.buttonStyle(PlainButtonStyle())
.help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles")
}
ZStack {
NavigationLink(destination: ArticleContainerView(), isActive: $navigate) {
EmptyView()
}.hidden()
List(timelineModel.timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in
TimelineItemView(timelineItem: timelineItem)
}
}
}
.navigationTitle(Text(verbatim: timelineModel.nameForDisplay))
#else
List(timelineModel.timelineItems) { timelineItem in
ZStack {
TimelineItemView(timelineItem: timelineItem)
NavigationLink(destination: ArticleContainerView(),
tag: timelineItem.article.articleID,
selection: $timelineModel.selectedArticleID) {
EmptyView()
}.buttonStyle(PlainButtonStyle())
.navigationTitle(Text(verbatim: timelineModel.nameForDisplay))
#else
List(timelineModel.timelineItems) { timelineItem in
ZStack {
TimelineItemView(width: proxy.size.width, timelineItem: timelineItem)
NavigationLink(destination: ArticleContainerView(),
tag: timelineItem.article.articleID,
selection: $timelineModel.selectedArticleID) {
EmptyView()
}.buttonStyle(PlainButtonStyle())
}
}
.navigationBarTitle(Text(verbatim: timelineModel.nameForDisplay), displayMode: .inline)
#endif
}
.navigationBarTitle(Text(verbatim: timelineModel.nameForDisplay), displayMode: .inline)
#endif
}
}

View File

@ -369,6 +369,8 @@
51B80F4224BE588200C6C32D /* SharingServicePickerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B80F4124BE588200C6C32D /* SharingServicePickerDelegate.swift */; };
51B80F4424BE58BF00C6C32D /* SharingServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B80F4324BE58BF00C6C32D /* SharingServiceDelegate.swift */; };
51B80F4624BF76E700C6C32D /* Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B80F4524BF76E700C6C32D /* Browser.swift */; };
51B8104524C0E6D200C6C32D /* TimelineTextSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B8104424C0E6D200C6C32D /* TimelineTextSizer.swift */; };
51B8104624C0E6D200C6C32D /* TimelineTextSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B8104424C0E6D200C6C32D /* TimelineTextSizer.swift */; };
51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; };
51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; };
51BC4AFF247277E0000A6ED8 /* URL-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC4ADD247277DF000A6ED8 /* URL-Extensions.swift */; };
@ -2030,6 +2032,7 @@
51B80F4124BE588200C6C32D /* SharingServicePickerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServicePickerDelegate.swift; sourceTree = "<group>"; };
51B80F4324BE58BF00C6C32D /* SharingServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServiceDelegate.swift; sourceTree = "<group>"; };
51B80F4524BF76E700C6C32D /* Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Browser.swift; sourceTree = "<group>"; };
51B8104424C0E6D200C6C32D /* TimelineTextSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTextSizer.swift; sourceTree = "<group>"; };
51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = "<group>"; };
51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = "<group>"; };
51BC4ADD247277DF000A6ED8 /* URL-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL-Extensions.swift"; sourceTree = "<group>"; };
@ -2940,6 +2943,7 @@
514E6BD924ACEA0400AC6F6E /* TimelineItemView.swift */,
51919FF024AB864A00541E64 /* TimelineModel.swift */,
5194737024BBCAF4001A2939 /* TimelineSortOrderView.swift */,
51B8104424C0E6D200C6C32D /* TimelineTextSizer.swift */,
5177470224B2657F00EB0F74 /* TimelineToolbarModifier.swift */,
51919FF624AB8B7700541E64 /* TimelineView.swift */,
);
@ -5095,6 +5099,7 @@
5177471C24B387AC00EB0F74 /* ImageScrollView.swift in Sources */,
51E498F824A8085D00B667CB /* UnreadFeed.swift in Sources */,
6591723124B5C35400B638E8 /* AccountHeaderImageView.swift in Sources */,
51B8104524C0E6D200C6C32D /* TimelineTextSizer.swift in Sources */,
FF64D0E724AF53EE0084080A /* RefreshProgressModel.swift in Sources */,
51E4996A24A8762D00B667CB /* ExtractedArticle.swift in Sources */,
51919FF124AB864A00541E64 /* TimelineModel.swift in Sources */,
@ -5277,6 +5282,7 @@
51E4991624A8090300B667CB /* ArticleUtilities.swift in Sources */,
51919FF224AB864A00541E64 /* TimelineModel.swift in Sources */,
51E4991A24A8090F00B667CB /* IconImage.swift in Sources */,
51B8104624C0E6D200C6C32D /* TimelineTextSizer.swift in Sources */,
51E4992724A80AAB00B667CB /* AppAssets.swift in Sources */,
51E49A0124A91FC100B667CB /* SidebarContainerView.swift in Sources */,
51E4995B24A875D500B667CB /* ArticlePasteboardWriter.swift in Sources */,