From 0a35a443b26a7feb772ea65003bba2500cb7a676 Mon Sep 17 00:00:00 2001 From: Grishka Date: Mon, 27 Mar 2023 04:09:03 +0300 Subject: [PATCH 01/10] Port the media layout algorithm from the Android app --- .../MastodonUI/Helper/MediaLayoutHelper.swift | 314 ++++++++++++++++++ .../Container/MediaGridContainerView.swift | 103 +++++- .../View/Container/MediaLayoutView.swift | 8 + .../Content/StatusView+Configuration.swift | 1 + .../View/Content/StatusView+ViewModel.swift | 12 +- 5 files changed, 423 insertions(+), 15 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift create mode 100644 MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift new file mode 100644 index 0000000000..365931538a --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -0,0 +1,314 @@ +// +// MediaLayoutHelper.swift +// +// +// Created by Grishka on 25.03.2023. +// + +import Foundation +import MastodonSDK +import CoreDataStack + +public struct MediaLayoutResult { + let width: Int + let height: Int + let columnSizes: [Int] + let rowSizes: [Int] + let tiles: [Tile] + + public struct Tile { + var colSpan: Int + let rowSpan: Int + var startCol: Int + let startRow: Int + var width: Int = 0 + } +} + +class MediaLayoutHelper { + static let maxWidth: Float = 1000 + static let maxHeight: Float = 1777 + static let minHeight: Float = 563 + static let gap: Float = 1.5 + static let maxRatio: Float = Float(maxWidth) / Float(maxHeight) + + public static func generateMediaLayout(attachments: [MastodonAttachment]) -> MediaLayoutResult? { + if attachments.count<2 { + return nil + } + + var ratios: [Float] = [] + var allAreWide = true + var allAreSquare = true + for att in attachments { + let ratio: Float = Float(att.size.width/att.size.height) + if ratio<=1.2 { + allAreWide = false + if ratio<0.8 { + allAreSquare = false + } + } else { + allAreSquare = false + } + ratios.append(ratio) + } + + let avgRatio: Float = ratios.reduce(0.0, +) / Float(ratios.count) + + switch attachments.count { + case 2: + if allAreWide && avgRatio>1.4*maxRatio && (ratios[1]-ratios[0])<0.2 { + // Two wide attachments, one above the other + let h = Int(max(min(maxWidth/ratios[0], min(maxWidth/ratios[1], (maxHeight-gap)/2.0)), minHeight/2.0).rounded()) + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((Float(h)*2.0+gap).rounded()), + columnSizes: [Int(maxWidth)], + rowSizes: [h, h], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) + ]) + } else if allAreWide || allAreSquare { + // Next to each other, same ratio + let w: Float = (maxWidth-gap) / 2.0 + let h: Float = max(min(w/ratios[0], min(w/ratios[1], maxHeight)), minHeight) + + let wInt: Int = Int(w.rounded()) + let hInt: Int = Int(h.rounded()) + + return MediaLayoutResult(width: Int(maxWidth), + height: hInt, + columnSizes: [wInt, Int(maxWidth)-wInt], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) + } else { + // Next to each other, different ratios + let w0: Float = ((maxWidth - gap) / ratios[1] / (1.0 / ratios[0] + 1.0 / ratios[1])) + let w1: Float = maxWidth - w0 - gap + let h: Float = max(min(maxHeight, min(w0/ratios[0], w1/ratios[1])), minHeight) + + let w0Int = Int(w0.rounded()) + let w1Int = Int(w1.rounded()) + let hInt = Int(h.rounded()) + + return MediaLayoutResult(width: Int((w0+w1+gap).rounded()), + height: hInt, + columnSizes: [w0Int, w1Int], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) + } + case 3: + if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { + // One above two smaller ones + var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) + let w2: Float = (maxWidth-gap)/2.0 + var h: Float = min(maxHeight-hCover-gap, min(w2/ratios[1], w2/ratios[2])) + if hCover+h < minHeight { + let prevTotalHeight = hCover+h + hCover = minHeight*(hCover/prevTotalHeight) + h = minHeight*(h/prevTotalHeight) + } + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((hCover+h+gap).rounded()), + columnSizes: [Int(w2.rounded()), Int(maxWidth-w2.rounded())], + rowSizes: [Int(hCover.rounded()), Int(h.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 2, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) + } else { + // One on the left, two smaller ones on the right + let height: Float = min(maxHeight, maxWidth*0.66/avgRatio) + let wCover: Float = min(height*ratios[0], (maxWidth-gap)*0.66) + let h1: Float = ratios[1]*(height-gap)/(ratios[2]+ratios[1]) + let h0: Float = height-h1-gap + let w: Float = min(maxWidth-wCover-gap, h1*ratios[2], h0*ratios[1]) + + return MediaLayoutResult(width: Int((wCover+w+gap).rounded()), + height: Int(height.rounded()), + columnSizes: [Int(wCover.rounded()), Int(w.rounded())], + rowSizes: [Int(h0.rounded()), Int(h1.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 2, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) + } + case 4: + if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { + // One above three smaller ones + var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) + var h: Float = (maxWidth-2.0*gap)/(ratios[1]+ratios[2]+ratios[3]) + let w0: Float = h*ratios[1] + let w1: Float = h*ratios[2] + h = min(maxHeight-hCover-gap, h) + if hCover+h1.1 { + for ratio in ratios { + ratiosCropped.append(max(1.0, ratio)) + } + } else { + for ratio in ratios { + ratiosCropped.append(min(1.0, ratio)) + } + } + + var tries: [[Int]: [Float]] = [:] + + // One line + tries[[attachments.count]] = [calculateMultiThumbsHeight(ratios: ratiosCropped, width: maxWidth, margin: gap)] + + // Two lines + for firstLine in 1...cnt-1 { + tries[[firstLine, cnt-firstLine]] = [ + calculateMultiThumbsHeight(ratios: Array(ratiosCropped[..1 && (conf[0]>conf[1] || (conf.count>2 && conf[1]>conf[2])) { + confDiff *= 1.1 + } + if confDiff Float { + return (width-(Float(ratios.count)-1.0)*margin)/ratios.reduce(0.0, +) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index b28f63942c..faa3521632 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -18,7 +18,7 @@ public protocol MediaGridContainerViewDelegate: AnyObject { public final class MediaGridContainerView: UIView { static let sensitiveToggleButtonSize = CGSize(width: 34, height: 34) - public static let maxCount = 9 + public static let maxCount = 10 let logger = Logger(subsystem: "MediaGridContainerView", category: "UI") @@ -183,11 +183,12 @@ extension MediaGridContainerView { let count: Int let maxSize: CGSize + let layout: MediaLayoutResult - init(count: Int, maxSize: CGSize) { - self.count = min(count, 9) + init(count: Int, maxSize: CGSize, layout: MediaLayoutResult) { + self.count = min(count, 10) self.maxSize = maxSize - + self.layout = layout } private func createStackView(axis: NSLayoutConstraint.Axis) -> UIStackView { @@ -200,13 +201,23 @@ extension MediaGridContainerView { } public func layout(in view: UIView, mediaViews: [MediaView]) { - let containerVerticalStackView = createStackView(axis: .vertical) - containerVerticalStackView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(containerVerticalStackView) - containerVerticalStackView.pinToParent() - let count = mediaViews.count - switch count { + + if count<2 || count>maxCount { + assertionFailure("unexpected attachment count \(count)") + return + } + + let layoutView = GridLayoutView() + layoutView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(layoutView) + layoutView.pinToParent() + for mediaView in mediaViews { + layoutView.addSubview(mediaView) + } + layoutView.prepare(layout: layout, maxSize: maxSize) + + /*switch count { case 1: assertionFailure("should use Adaptive Layout") containerVerticalStackView.addArrangedSubview(mediaViews[0]) @@ -260,10 +271,10 @@ extension MediaGridContainerView { default: assertionFailure() return - } + }*/ let containerWidth = maxSize.width - let containerHeight = count > 6 ? containerWidth : containerWidth * 2 / 3 + let containerHeight = CGFloat(layoutView.getMeasuredHeight()) NSLayoutConstraint.activate([ view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), @@ -271,3 +282,71 @@ extension MediaGridContainerView { } } } + +class GridLayoutView : UIView { + private var layout: MediaLayoutResult? + private var measuredHeight = 0 + + private static let maxWidth = 400 + private static let gap = 2 + + public func prepare(layout: MediaLayoutResult, maxSize: CGSize) { + self.layout = layout + let width: Float = min(Float(maxSize.width), Float(GridLayoutView.maxWidth)) + let height: Float = (width*Float(layout.height)/MediaLayoutHelper.maxWidth) + measuredHeight = Int(height.rounded()) + } + + public func getMeasuredHeight() -> Int { + return measuredHeight + } + + override func layoutSubviews() { + guard let layout = layout else { return } + var width: Int = min(GridLayoutView.maxWidth, Int(frame.width)) + let height: Int = Int(frame.height) + if layout.widthwidth { + xOffset = Int((Float(frame.width)/2.0-Float(width)/2.0).rounded()) + } + + for (i, view) in subviews.enumerated() { + if i>=layout.tiles.count { + break // TODO make sure any additional subviews are only added at the end + } + let tile = layout.tiles[i] + let colSpan = max(1, tile.colSpan) - 1 + let rowSpan = max(1, tile.rowSpan) - 1 + let x = columnStarts[tile.startCol] + let y = rowStarts[tile.startRow] + view.frame = CGRect(x: x+xOffset, y: y, width: columnEnds[tile.startCol+colSpan]-x, height: rowEnds[tile.startRow+rowSpan]-y) + } + } +} + diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift new file mode 100644 index 0000000000..cb5b2362aa --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift @@ -0,0 +1,8 @@ +// +// MediaLayoutView.swift +// +// +// Created by Grishka on 27.03.2023. +// + +import Foundation diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index 03eff8c27d..c04b9b3e6b 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -389,6 +389,7 @@ extension StatusView { let configurations = MediaView.configuration(status: status) viewModel.mediaViewConfigurations = configurations + viewModel.mediaLayout = MediaLayoutHelper.generateMediaLayout(attachments: status.attachments) } private func configurePollHistory(statusEdit: StatusEdit) { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 0bdcb62433..413ac39db4 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -65,6 +65,7 @@ extension StatusView { // Media @Published public var mediaViewConfigurations: [MediaView.Configuration] = [] + @Published public var mediaLayout: MediaLayoutResult? = nil // Audio @Published public var audioConfigurations: [MediaView.Configuration] = [] @@ -389,8 +390,11 @@ extension StatusView.ViewModel { } private func bindMedia(statusView: StatusView) { - $mediaViewConfigurations - .sink { [weak self] configurations in + Publishers.CombineLatest( + $mediaViewConfigurations, + $mediaLayout + ) + .sink { [weak self] configurations, mediaLayout in guard let self = self else { return } self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") @@ -413,9 +417,11 @@ extension StatusView.ViewModel { let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) mediaView.setup(configuration: configuration) default: + guard let mediaLayout = mediaLayout else { return } let gridLayout = MediaGridContainerView.GridLayout( count: configurations.count, - maxSize: maxSize + maxSize: maxSize, + layout: mediaLayout ) let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { From 236899782948602cf653277a5cbcb662ae737b40 Mon Sep 17 00:00:00 2001 From: Grishka Date: Mon, 27 Mar 2023 04:15:49 +0300 Subject: [PATCH 02/10] I forgot to delete a file I ended up not needing --- .../MastodonUI/View/Container/MediaLayoutView.swift | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift deleted file mode 100644 index cb5b2362aa..0000000000 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaLayoutView.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// MediaLayoutView.swift -// -// -// Created by Grishka on 27.03.2023. -// - -import Foundation From 0d2434b26aa58dc3d5b2d0f3bca48c2b67395e2d Mon Sep 17 00:00:00 2001 From: Grishka Date: Mon, 27 Mar 2023 04:24:00 +0300 Subject: [PATCH 03/10] Also forgot to delete commented out code --- .../Container/MediaGridContainerView.swift | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index faa3521632..ab96153893 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -216,62 +216,6 @@ extension MediaGridContainerView { layoutView.addSubview(mediaView) } layoutView.prepare(layout: layout, maxSize: maxSize) - - /*switch count { - case 1: - assertionFailure("should use Adaptive Layout") - containerVerticalStackView.addArrangedSubview(mediaViews[0]) - case 2: - let horizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(horizontalStackView) - horizontalStackView.addArrangedSubview(mediaViews[0]) - horizontalStackView.addArrangedSubview(mediaViews[1]) - case 3: - let horizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(horizontalStackView) - horizontalStackView.addArrangedSubview(mediaViews[0]) - - let verticalStackView = createStackView(axis: .vertical) - horizontalStackView.addArrangedSubview(verticalStackView) - verticalStackView.addArrangedSubview(mediaViews[1]) - verticalStackView.addArrangedSubview(mediaViews[2]) - case 4: - let topHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(topHorizontalStackView) - topHorizontalStackView.addArrangedSubview(mediaViews[0]) - topHorizontalStackView.addArrangedSubview(mediaViews[1]) - - let bottomHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) - bottomHorizontalStackView.addArrangedSubview(mediaViews[2]) - bottomHorizontalStackView.addArrangedSubview(mediaViews[3]) - case 5...9: - let topHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(topHorizontalStackView) - topHorizontalStackView.addArrangedSubview(mediaViews[0]) - topHorizontalStackView.addArrangedSubview(mediaViews[1]) - topHorizontalStackView.addArrangedSubview(mediaViews[2]) - - func mediaViewOrPlaceholderView(at index: Int) -> UIView { - return index < mediaViews.count ? mediaViews[index] : UIView() - } - let middleHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(middleHorizontalStackView) - middleHorizontalStackView.addArrangedSubview(mediaViews[3]) - middleHorizontalStackView.addArrangedSubview(mediaViews[4]) - middleHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 5)) - - if count > 6 { - let bottomHorizontalStackView = createStackView(axis: .horizontal) - containerVerticalStackView.addArrangedSubview(bottomHorizontalStackView) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 6)) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 7)) - bottomHorizontalStackView.addArrangedSubview(mediaViewOrPlaceholderView(at: 8)) - } - default: - assertionFailure() - return - }*/ let containerWidth = maxSize.width let containerHeight = CGFloat(layoutView.getMeasuredHeight()) From 8145c0e23885c3f2c889fa4ce06782f9c2e656a0 Mon Sep 17 00:00:00 2001 From: Gregory K Date: Mon, 27 Mar 2023 08:48:57 +0300 Subject: [PATCH 04/10] Apply suggestions from code review Co-authored-by: Jed Fox --- .../Sources/MastodonUI/Helper/MediaLayoutHelper.swift | 6 +++--- .../MastodonUI/View/Container/MediaGridContainerView.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift index 365931538a..01a9352943 100644 --- a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -30,7 +30,7 @@ class MediaLayoutHelper { static let maxHeight: Float = 1777 static let minHeight: Float = 563 static let gap: Float = 1.5 - static let maxRatio: Float = Float(maxWidth) / Float(maxHeight) + static let maxRatio = maxWidth / maxHeight public static func generateMediaLayout(attachments: [MastodonAttachment]) -> MediaLayoutResult? { if attachments.count<2 { @@ -42,7 +42,7 @@ class MediaLayoutHelper { var allAreSquare = true for att in attachments { let ratio: Float = Float(att.size.width/att.size.height) - if ratio<=1.2 { + if ratio <= 1.2 { allAreWide = false if ratio<0.8 { allAreSquare = false @@ -282,7 +282,7 @@ class MediaLayoutHelper { columnSizes.append(offset - gridLineOffsets[i]) // i is already offset by one here } - for (row, _) in rowTiles.enumerated() { + for row in 0.. Date: Mon, 27 Mar 2023 21:48:58 +0300 Subject: [PATCH 05/10] Fix indents --- .../MastodonUI/Helper/MediaLayoutHelper.swift | 596 +++++++++--------- .../Container/MediaGridContainerView.swift | 172 ++--- .../View/Content/StatusView+ViewModel.swift | 312 ++++----- 3 files changed, 540 insertions(+), 540 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift index 01a9352943..b0dfb40590 100644 --- a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -10,305 +10,305 @@ import MastodonSDK import CoreDataStack public struct MediaLayoutResult { - let width: Int - let height: Int - let columnSizes: [Int] - let rowSizes: [Int] - let tiles: [Tile] - - public struct Tile { - var colSpan: Int - let rowSpan: Int - var startCol: Int - let startRow: Int - var width: Int = 0 - } + let width: Int + let height: Int + let columnSizes: [Int] + let rowSizes: [Int] + let tiles: [Tile] + + public struct Tile { + var colSpan: Int + let rowSpan: Int + var startCol: Int + let startRow: Int + var width: Int = 0 + } } class MediaLayoutHelper { - static let maxWidth: Float = 1000 - static let maxHeight: Float = 1777 - static let minHeight: Float = 563 - static let gap: Float = 1.5 - static let maxRatio = maxWidth / maxHeight - - public static func generateMediaLayout(attachments: [MastodonAttachment]) -> MediaLayoutResult? { - if attachments.count<2 { - return nil - } - - var ratios: [Float] = [] - var allAreWide = true - var allAreSquare = true - for att in attachments { - let ratio: Float = Float(att.size.width/att.size.height) - if ratio <= 1.2 { - allAreWide = false - if ratio<0.8 { - allAreSquare = false - } - } else { - allAreSquare = false - } - ratios.append(ratio) - } - - let avgRatio: Float = ratios.reduce(0.0, +) / Float(ratios.count) - - switch attachments.count { - case 2: - if allAreWide && avgRatio>1.4*maxRatio && (ratios[1]-ratios[0])<0.2 { - // Two wide attachments, one above the other - let h = Int(max(min(maxWidth/ratios[0], min(maxWidth/ratios[1], (maxHeight-gap)/2.0)), minHeight/2.0).rounded()) - - return MediaLayoutResult(width: Int(maxWidth), - height: Int((Float(h)*2.0+gap).rounded()), - columnSizes: [Int(maxWidth)], - rowSizes: [h, h], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) - ]) - } else if allAreWide || allAreSquare { - // Next to each other, same ratio - let w: Float = (maxWidth-gap) / 2.0 - let h: Float = max(min(w/ratios[0], min(w/ratios[1], maxHeight)), minHeight) - - let wInt: Int = Int(w.rounded()) - let hInt: Int = Int(h.rounded()) - - return MediaLayoutResult(width: Int(maxWidth), - height: hInt, - columnSizes: [wInt, Int(maxWidth)-wInt], - rowSizes: [hInt], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) - ]) - } else { - // Next to each other, different ratios - let w0: Float = ((maxWidth - gap) / ratios[1] / (1.0 / ratios[0] + 1.0 / ratios[1])) - let w1: Float = maxWidth - w0 - gap - let h: Float = max(min(maxHeight, min(w0/ratios[0], w1/ratios[1])), minHeight) - - let w0Int = Int(w0.rounded()) - let w1Int = Int(w1.rounded()) - let hInt = Int(h.rounded()) - - return MediaLayoutResult(width: Int((w0+w1+gap).rounded()), - height: hInt, - columnSizes: [w0Int, w1Int], - rowSizes: [hInt], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) - ]) - } - case 3: - if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { - // One above two smaller ones - var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) - let w2: Float = (maxWidth-gap)/2.0 - var h: Float = min(maxHeight-hCover-gap, min(w2/ratios[1], w2/ratios[2])) - if hCover+h < minHeight { - let prevTotalHeight = hCover+h - hCover = minHeight*(hCover/prevTotalHeight) - h = minHeight*(h/prevTotalHeight) - } - - return MediaLayoutResult(width: Int(maxWidth), - height: Int((hCover+h+gap).rounded()), - columnSizes: [Int(w2.rounded()), Int(maxWidth-w2.rounded())], - rowSizes: [Int(hCover.rounded()), Int(h.rounded())], - tiles: [ - MediaLayoutResult.Tile(colSpan: 2, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) - ]) - } else { - // One on the left, two smaller ones on the right - let height: Float = min(maxHeight, maxWidth*0.66/avgRatio) - let wCover: Float = min(height*ratios[0], (maxWidth-gap)*0.66) - let h1: Float = ratios[1]*(height-gap)/(ratios[2]+ratios[1]) - let h0: Float = height-h1-gap - let w: Float = min(maxWidth-wCover-gap, h1*ratios[2], h0*ratios[1]) - - return MediaLayoutResult(width: Int((wCover+w+gap).rounded()), - height: Int(height.rounded()), - columnSizes: [Int(wCover.rounded()), Int(w.rounded())], - rowSizes: [Int(h0.rounded()), Int(h1.rounded())], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 2, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) - ]) - } - case 4: - if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { - // One above three smaller ones - var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) - var h: Float = (maxWidth-2.0*gap)/(ratios[1]+ratios[2]+ratios[3]) - let w0: Float = h*ratios[1] - let w1: Float = h*ratios[2] - h = min(maxHeight-hCover-gap, h) - if hCover+h1.1 { - for ratio in ratios { - ratiosCropped.append(max(1.0, ratio)) - } - } else { - for ratio in ratios { - ratiosCropped.append(min(1.0, ratio)) - } - } - - var tries: [[Int]: [Float]] = [:] - - // One line - tries[[attachments.count]] = [calculateMultiThumbsHeight(ratios: ratiosCropped, width: maxWidth, margin: gap)] - - // Two lines - for firstLine in 1...cnt-1 { - tries[[firstLine, cnt-firstLine]] = [ - calculateMultiThumbsHeight(ratios: Array(ratiosCropped[..1 && (conf[0]>conf[1] || (conf.count>2 && conf[1]>conf[2])) { - confDiff *= 1.1 - } - if confDiff Float { - return (width-(Float(ratios.count)-1.0)*margin)/ratios.reduce(0.0, +) - } + static let maxWidth: Float = 1000 + static let maxHeight: Float = 1777 + static let minHeight: Float = 563 + static let gap: Float = 1.5 + static let maxRatio = maxWidth / maxHeight + + public static func generateMediaLayout(attachments: [MastodonAttachment]) -> MediaLayoutResult? { + if attachments.count<2 { + return nil + } + + var ratios: [Float] = [] + var allAreWide = true + var allAreSquare = true + for att in attachments { + let ratio: Float = Float(att.size.width/att.size.height) + if ratio <= 1.2 { + allAreWide = false + if ratio<0.8 { + allAreSquare = false + } + } else { + allAreSquare = false + } + ratios.append(ratio) + } + + let avgRatio: Float = ratios.reduce(0.0, +) / Float(ratios.count) + + switch attachments.count { + case 2: + if allAreWide && avgRatio>1.4*maxRatio && (ratios[1]-ratios[0])<0.2 { + // Two wide attachments, one above the other + let h = Int(max(min(maxWidth/ratios[0], min(maxWidth/ratios[1], (maxHeight-gap)/2.0)), minHeight/2.0).rounded()) + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((Float(h)*2.0+gap).rounded()), + columnSizes: [Int(maxWidth)], + rowSizes: [h, h], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) + ]) + } else if allAreWide || allAreSquare { + // Next to each other, same ratio + let w: Float = (maxWidth-gap) / 2.0 + let h: Float = max(min(w/ratios[0], min(w/ratios[1], maxHeight)), minHeight) + + let wInt: Int = Int(w.rounded()) + let hInt: Int = Int(h.rounded()) + + return MediaLayoutResult(width: Int(maxWidth), + height: hInt, + columnSizes: [wInt, Int(maxWidth)-wInt], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) + } else { + // Next to each other, different ratios + let w0: Float = ((maxWidth - gap) / ratios[1] / (1.0 / ratios[0] + 1.0 / ratios[1])) + let w1: Float = maxWidth - w0 - gap + let h: Float = max(min(maxHeight, min(w0/ratios[0], w1/ratios[1])), minHeight) + + let w0Int = Int(w0.rounded()) + let w1Int = Int(w1.rounded()) + let hInt = Int(h.rounded()) + + return MediaLayoutResult(width: Int((w0+w1+gap).rounded()), + height: hInt, + columnSizes: [w0Int, w1Int], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) + } + case 3: + if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { + // One above two smaller ones + var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) + let w2: Float = (maxWidth-gap)/2.0 + var h: Float = min(maxHeight-hCover-gap, min(w2/ratios[1], w2/ratios[2])) + if hCover+h < minHeight { + let prevTotalHeight = hCover+h + hCover = minHeight*(hCover/prevTotalHeight) + h = minHeight*(h/prevTotalHeight) + } + + return MediaLayoutResult(width: Int(maxWidth), + height: Int((hCover+h+gap).rounded()), + columnSizes: [Int(w2.rounded()), Int(maxWidth-w2.rounded())], + rowSizes: [Int(hCover.rounded()), Int(h.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 2, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) + } else { + // One on the left, two smaller ones on the right + let height: Float = min(maxHeight, maxWidth*0.66/avgRatio) + let wCover: Float = min(height*ratios[0], (maxWidth-gap)*0.66) + let h1: Float = ratios[1]*(height-gap)/(ratios[2]+ratios[1]) + let h0: Float = height-h1-gap + let w: Float = min(maxWidth-wCover-gap, h1*ratios[2], h0*ratios[1]) + + return MediaLayoutResult(width: Int((wCover+w+gap).rounded()), + height: Int(height.rounded()), + columnSizes: [Int(wCover.rounded()), Int(w.rounded())], + rowSizes: [Int(h0.rounded()), Int(h1.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 2, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) + } + case 4: + if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { + // One above three smaller ones + var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) + var h: Float = (maxWidth-2.0*gap)/(ratios[1]+ratios[2]+ratios[3]) + let w0: Float = h*ratios[1] + let w1: Float = h*ratios[2] + h = min(maxHeight-hCover-gap, h) + if hCover+h1.1 { + for ratio in ratios { + ratiosCropped.append(max(1.0, ratio)) + } + } else { + for ratio in ratios { + ratiosCropped.append(min(1.0, ratio)) + } + } + + var tries: [[Int]: [Float]] = [:] + + // One line + tries[[attachments.count]] = [calculateMultiThumbsHeight(ratios: ratiosCropped, width: maxWidth, margin: gap)] + + // Two lines + for firstLine in 1...cnt-1 { + tries[[firstLine, cnt-firstLine]] = [ + calculateMultiThumbsHeight(ratios: Array(ratiosCropped[..1 && (conf[0]>conf[1] || (conf.count>2 && conf[1]>conf[2])) { + confDiff *= 1.1 + } + if confDiff Float { + return (width-(Float(ratios.count)-1.0)*margin)/ratios.reduce(0.0, +) + } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index 4c68012589..501edf9a08 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -66,7 +66,7 @@ public final class MediaGridContainerView: UIView { } set { } } - + } extension MediaGridContainerView { @@ -83,7 +83,7 @@ extension MediaGridContainerView { let mediaView = _mediaViews[index] delegate?.mediaGridContainerView(self, didTapMediaView: mediaView, at: index) } - + @objc private func sensitiveToggleButtonDidPressed(_ sender: UIButton) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") delegate?.mediaGridContainerView(self, mediaSensitiveButtonDidPressed: sender) @@ -91,7 +91,7 @@ extension MediaGridContainerView { } extension MediaGridContainerView { - + public func dequeueMediaView(adaptiveLayout layout: AdaptiveLayout) -> MediaView { prepareForReuse() @@ -129,7 +129,7 @@ extension MediaGridContainerView { removeConstraints(constraints) } - + } extension MediaGridContainerView { @@ -183,12 +183,12 @@ extension MediaGridContainerView { let count: Int let maxSize: CGSize - let layout: MediaLayoutResult + let layout: MediaLayoutResult - init(count: Int, maxSize: CGSize, layout: MediaLayoutResult) { + init(count: Int, maxSize: CGSize, layout: MediaLayoutResult) { self.count = min(count, 10) self.maxSize = maxSize - self.layout = layout + self.layout = layout } private func createStackView(axis: NSLayoutConstraint.Axis) -> UIStackView { @@ -202,23 +202,23 @@ extension MediaGridContainerView { public func layout(in view: UIView, mediaViews: [MediaView]) { let count = mediaViews.count - - if count<2 || count>maxCount { - assertionFailure("unexpected attachment count \(count)") - return - } - - let layoutView = GridLayoutView() - layoutView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(layoutView) - layoutView.pinToParent() - for mediaView in mediaViews { - layoutView.addSubview(mediaView) - } - layoutView.prepare(layout: layout, maxSize: maxSize) + + if count<2 || count>maxCount { + assertionFailure("unexpected attachment count \(count)") + return + } + + let layoutView = GridLayoutView() + layoutView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(layoutView) + layoutView.pinToParent() + for mediaView in mediaViews { + layoutView.addSubview(mediaView) + } + layoutView.prepare(layout: layout, maxSize: maxSize) let containerWidth = maxSize.width - let containerHeight = CGFloat(layoutView.getMeasuredHeight()) + let containerHeight = CGFloat(layoutView.getMeasuredHeight()) NSLayoutConstraint.activate([ view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), @@ -228,69 +228,69 @@ extension MediaGridContainerView { } class GridLayoutView : UIView { - private var layout: MediaLayoutResult? - private(set) var measuredHeight = 0 - - private static let maxWidth = 400 - private static let gap = 2 - - public func prepare(layout: MediaLayoutResult, maxSize: CGSize) { - self.layout = layout - let width: Float = min(Float(maxSize.width), Float(GridLayoutView.maxWidth)) - let height: Float = (width*Float(layout.height)/MediaLayoutHelper.maxWidth) - measuredHeight = Int(height.rounded()) - } - - public func getMeasuredHeight() -> Int { - return measuredHeight - } - - override func layoutSubviews() { - guard let layout = layout else { return } - var width: Int = min(GridLayoutView.maxWidth, Int(frame.width)) - let height: Int = Int(frame.height) - if layout.widthwidth { - xOffset = Int((Float(frame.width)/2.0-Float(width)/2.0).rounded()) - } - - for (i, view) in subviews.enumerated() { - if i>=layout.tiles.count { - break // TODO make sure any additional subviews are only added at the end - } - let tile = layout.tiles[i] - let colSpan = max(1, tile.colSpan) - 1 - let rowSpan = max(1, tile.rowSpan) - 1 - let x = columnStarts[tile.startCol] - let y = rowStarts[tile.startRow] - view.frame = CGRect(x: x+xOffset, y: y, width: columnEnds[tile.startCol+colSpan]-x, height: rowEnds[tile.startRow+rowSpan]-y) - } - } + private var layout: MediaLayoutResult? + private(set) var measuredHeight = 0 + + private static let maxWidth = 400 + private static let gap = 2 + + public func prepare(layout: MediaLayoutResult, maxSize: CGSize) { + self.layout = layout + let width: Float = min(Float(maxSize.width), Float(GridLayoutView.maxWidth)) + let height: Float = (width*Float(layout.height)/MediaLayoutHelper.maxWidth) + measuredHeight = Int(height.rounded()) + } + + public func getMeasuredHeight() -> Int { + return measuredHeight + } + + override func layoutSubviews() { + guard let layout = layout else { return } + var width: Int = min(GridLayoutView.maxWidth, Int(frame.width)) + let height: Int = Int(frame.height) + if layout.widthwidth { + xOffset = Int((Float(frame.width)/2.0-Float(width)/2.0).rounded()) + } + + for (i, view) in subviews.enumerated() { + if i>=layout.tiles.count { + break // TODO make sure any additional subviews are only added at the end + } + let tile = layout.tiles[i] + let colSpan = max(1, tile.colSpan) - 1 + let rowSpan = max(1, tile.rowSpan) - 1 + let x = columnStarts[tile.startCol] + let y = rowStarts[tile.startRow] + view.frame = CGRect(x: x+xOffset, y: y, width: columnEnds[tile.startCol+colSpan]-x, height: rowEnds[tile.startRow+rowSpan]-y) + } + } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index 413ac39db4..fea3bfd67a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -24,13 +24,13 @@ extension StatusView { var disposeBag = Set() var observations = Set() public var objects = Set() - + let logger = Logger(subsystem: "StatusView", category: "ViewModel") public var context: AppContext? public var authContext: AuthContext? public var originalStatus: Status? - + // Header @Published public var header: Header = .none @@ -50,7 +50,7 @@ extension StatusView { @Published public var isCurrentlyTranslating = false @Published public var translatedFromLanguage: String? @Published public var translatedUsingProvider: String? - + @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date, _ isEdited: Bool) -> String)? @Published public var timestampText = "" @@ -65,7 +65,7 @@ extension StatusView { // Media @Published public var mediaViewConfigurations: [MediaView.Configuration] = [] - @Published public var mediaLayout: MediaLayoutResult? = nil + @Published public var mediaLayout: MediaLayoutResult? = nil // Audio @Published public var audioConfigurations: [MediaView.Configuration] = [] @@ -79,10 +79,10 @@ extension StatusView { @Published public var voteCount = 0 @Published public var expireAt: Date? @Published public var expired: Bool = false - + // Card @Published public var card: Card? - + // Visibility @Published public var visibility: MastodonVisibility = .public @@ -103,7 +103,7 @@ extension StatusView { @Published public var replyCount: Int = 0 @Published public var reblogCount: Int = 0 @Published public var favoriteCount: Int = 0 - + @Published public var statusEdits: [StatusEdit] = [] @Published public var editedAt: Date? = nil @@ -111,10 +111,10 @@ extension StatusView { @Published public var activeFilters: [Mastodon.Entity.Filter] = [] @Published public var filterContext: Mastodon.Entity.Filter.Context? @Published public var isFiltered = false - + @Published public var groupedAccessibilityLabel = "" @Published public var contentAccessibilityLabel = "" - + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .share() @@ -306,7 +306,7 @@ extension StatusView.ViewModel { statusView.spoilerOverlayView.spoilerMetaLabel.configure(content: spoilerContent) // statusView.spoilerBannerView.label.configure(content: spoilerContent) // statusView.setSpoilerBannerViewHidden(isHidden: !isContentReveal) - + } else { statusView.spoilerOverlayView.spoilerMetaLabel.reset() // statusView.spoilerBannerView.label.reset() @@ -323,7 +323,7 @@ extension StatusView.ViewModel { } let paragraphStyle = statusView.contentMetaText.paragraphStyle - if let language = language { + if let language = language { if #available(iOS 16, *) { let direction = Locale.Language(identifier: language).characterDirection paragraphStyle.alignment = direction == .rightToLeft ? .right : .left @@ -343,7 +343,7 @@ extension StatusView.ViewModel { statusView.contentMetaText.textView.accessibilityTraits = [.staticText] statusView.contentMetaText.textView.accessibilityElementsHidden = false statusView.contentMetaText.textView.isHidden = false - + } else { statusView.contentMetaText.reset() statusView.contentMetaText.textView.accessibilityLabel = "" @@ -358,7 +358,7 @@ extension StatusView.ViewModel { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isContentReveal: \(isContentReveal)") } .store(in: &disposeBag) - + $isMediaSensitive .sink { isSensitive in guard isSensitive else { return } @@ -375,7 +375,7 @@ extension StatusView.ViewModel { statusView.authorView.contentSensitiveeToggleButton.setImage(image, for: .normal) } .store(in: &disposeBag) - + $isCurrentlyTranslating .receive(on: DispatchQueue.main) .sink { isTranslating in @@ -390,50 +390,50 @@ extension StatusView.ViewModel { } private func bindMedia(statusView: StatusView) { - Publishers.CombineLatest( - $mediaViewConfigurations, - $mediaLayout - ) + Publishers.CombineLatest( + $mediaViewConfigurations, + $mediaLayout + ) .sink { [weak self] configurations, mediaLayout in - guard let self = self else { return } - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") - - statusView.mediaGridContainerView.prepareForReuse() - - let maxSize = CGSize( - width: statusView.contentMaxLayoutWidth, - height: 9999 // fulfill the width + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") + + statusView.mediaGridContainerView.prepareForReuse() + + let maxSize = CGSize( + width: statusView.contentMaxLayoutWidth, + height: 9999 // fulfill the width + ) + var needsDisplay = true + switch configurations.count { + case 0: + needsDisplay = false + case 1: + let configuration = configurations[0] + let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( + aspectRatio: configuration.aspectRadio, + maxSize: maxSize ) - var needsDisplay = true - switch configurations.count { - case 0: - needsDisplay = false - case 1: - let configuration = configurations[0] - let adaptiveLayout = MediaGridContainerView.AdaptiveLayout( - aspectRatio: configuration.aspectRadio, - maxSize: maxSize - ) - let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) + let mediaView = statusView.mediaGridContainerView.dequeueMediaView(adaptiveLayout: adaptiveLayout) + mediaView.setup(configuration: configuration) + default: + guard let mediaLayout = mediaLayout else { return } + let gridLayout = MediaGridContainerView.GridLayout( + count: configurations.count, + maxSize: maxSize, + layout: mediaLayout + ) + let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) + for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { + guard i < MediaGridContainerView.maxCount else { break } mediaView.setup(configuration: configuration) - default: - guard let mediaLayout = mediaLayout else { return } - let gridLayout = MediaGridContainerView.GridLayout( - count: configurations.count, - maxSize: maxSize, - layout: mediaLayout - ) - let mediaViews = statusView.mediaGridContainerView.dequeueMediaView(gridLayout: gridLayout) - for (i, (configuration, mediaView)) in zip(configurations, mediaViews).enumerated() { - guard i < MediaGridContainerView.maxCount else { break } - mediaView.setup(configuration: configuration) - } - } - if needsDisplay { - statusView.setMediaDisplay() } } - .store(in: &disposeBag) + if needsDisplay { + statusView.setMediaDisplay() + } + } + .store(in: &disposeBag) Publishers.CombineLatest( $mediaViewConfigurations, @@ -478,32 +478,32 @@ extension StatusView.ViewModel { $voterCount, $voteCount ) - .map { voterCount, voteCount -> String in - var description = "" - if let voterCount = voterCount { - description += L10n.Plural.Count.voter(voterCount) - } else { - description += L10n.Plural.Count.vote(voteCount) + .map { voterCount, voteCount -> String in + var description = "" + if let voterCount = voterCount { + description += L10n.Plural.Count.voter(voterCount) + } else { + description += L10n.Plural.Count.vote(voteCount) + } + return description } - return description - } let pollCountdownDescription = Publishers.CombineLatest3( $expireAt, $expired, timestampUpdatePublisher.prepend(Date()).eraseToAnyPublisher() ) - .map { expireAt, expired, _ -> String? in - guard !expired else { - return L10n.Common.Controls.Status.Poll.closed - } - - guard let expireAt = expireAt else { - return nil + .map { expireAt, expired, _ -> String? in + guard !expired else { + return L10n.Common.Controls.Status.Poll.closed + } + + guard let expireAt = expireAt else { + return nil + } + let timeLeft = expireAt.localizedTimeLeft() + + return timeLeft } - let timeLeft = expireAt.localizedTimeLeft() - - return timeLeft - } Publishers.CombineLatest( pollVoteDescription, pollCountdownDescription @@ -523,7 +523,7 @@ extension StatusView.ViewModel { statusView.pollVoteActivityIndicatorView.isHidden = true return } - + statusView.pollVoteButton.isHidden = isVoting statusView.pollVoteActivityIndicatorView.isHidden = !isVoting statusView.pollVoteActivityIndicatorView.startAnimating() @@ -533,7 +533,7 @@ extension StatusView.ViewModel { .assign(to: \.isEnabled, on: statusView.pollVoteButton) .store(in: &disposeBag) } - + private func bindCard(statusView: StatusView) { $card.sink { card in guard let card = card else { return } @@ -615,14 +615,14 @@ extension StatusView.ViewModel { } return formatter.string(from: timestamp) }() - + let text: String if let applicationName { text = L10n.Common.Controls.Status.postedViaApplication(dateString, applicationName) } else { text = dateString } - + statusView.statusMetricView.dateLabel.text = text } .store(in: &disposeBag) @@ -640,7 +640,7 @@ extension StatusView.ViewModel { statusView.statusMetricView.favoriteButton.detailLabel.text = count.formatted() } .store(in: &disposeBag) - + $editedAt .sink { editedAt in if let editedAt { @@ -676,49 +676,49 @@ extension StatusView.ViewModel { publishersTwo.eraseToAnyPublisher(), publishersThree.eraseToAnyPublisher() ).eraseToAnyPublisher() - .sink { tupleOne, tupleTwo, tupleThree in - let (authorName, isMyself) = tupleOne - let (isMuting, isBlocking, isBookmark) = tupleTwo - let (translatedFromLanguage, language) = tupleThree - - guard let name = authorName?.string else { - statusView.authorView.menuButton.menu = nil - return - } - - lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = { - guard - let context = self.context, - let authContext = self.authContext - else { - return nil - } + .sink { tupleOne, tupleTwo, tupleThree in + let (authorName, isMyself) = tupleOne + let (isMuting, isBlocking, isBookmark) = tupleTwo + let (translatedFromLanguage, language) = tupleThree - var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil - context.managedObjectContext.performAndWait { - guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) - else { return } - configuration = authentication.instance?.configurationV2 + guard let name = authorName?.string else { + statusView.authorView.menuButton.menu = nil + return } - return configuration - }() - - let menuContext = StatusAuthorView.AuthorMenuContext( - name: name, - isMuting: isMuting, - isBlocking: isBlocking, - isMyself: isMyself, - isBookmarking: isBookmark, - isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, - isTranslated: translatedFromLanguage != nil, - statusLanguage: language - ) - let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) - authorView.menuButton.menu = menu - authorView.authorActions = actions - authorView.menuButton.showsMenuAsPrimaryAction = true - } - .store(in: &disposeBag) + + lazy var instanceConfigurationV2: Mastodon.Entity.V2.Instance.Configuration? = { + guard + let context = self.context, + let authContext = self.authContext + else { + return nil + } + + var configuration: Mastodon.Entity.V2.Instance.Configuration? = nil + context.managedObjectContext.performAndWait { + guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) + else { return } + configuration = authentication.instance?.configurationV2 + } + return configuration + }() + + let menuContext = StatusAuthorView.AuthorMenuContext( + name: name, + isMuting: isMuting, + isBlocking: isBlocking, + isMyself: isMyself, + isBookmarking: isBookmark, + isTranslationEnabled: instanceConfigurationV2?.translation?.enabled == true, + isTranslated: translatedFromLanguage != nil, + statusLanguage: language + ) + let (menu, actions) = authorView.setupAuthorMenu(menuContext: menuContext) + authorView.menuButton.menu = menu + authorView.authorActions = actions + authorView.menuButton.showsMenuAsPrimaryAction = true + } + .store(in: &disposeBag) } private func bindFilter(statusView: StatusView) { @@ -726,7 +726,7 @@ extension StatusView.ViewModel { .sink { isFiltered in statusView.containerStackView.isHidden = isFiltered if isFiltered { - statusView.setFilterHintLabelDisplay() + statusView.setFilterHintLabelDisplay() } } .store(in: &disposeBag) @@ -739,30 +739,30 @@ extension StatusView.ViewModel { $authorUsername, $timestampText ) - .map { header, authorName, authorUsername, timestamp -> String? in - var strings: [String?] = [] - - switch header { - case .none: - strings.append(authorName?.string) - strings.append(authorUsername) - case .reply(let info): - strings.append(authorName?.string) - strings.append(authorUsername) - strings.append(info.header.string) - case .repost(let info): - strings.append(info.header.string) - strings.append(authorName?.string) - strings.append(authorUsername) - } - - if statusView.style != .editHistory { - strings.append(timestamp) + .map { header, authorName, authorUsername, timestamp -> String? in + var strings: [String?] = [] + + switch header { + case .none: + strings.append(authorName?.string) + strings.append(authorUsername) + case .reply(let info): + strings.append(authorName?.string) + strings.append(authorUsername) + strings.append(info.header.string) + case .repost(let info): + strings.append(info.header.string) + strings.append(authorName?.string) + strings.append(authorUsername) + } + + if statusView.style != .editHistory { + strings.append(timestamp) + } + + return strings.compactMap { $0 }.joined(separator: ", ") } - - return strings.compactMap { $0 }.joined(separator: ", ") - } - + let longTimestampFormatter = DateFormatter() longTimestampFormatter.dateStyle = .medium longTimestampFormatter.timeStyle = .short @@ -778,7 +778,7 @@ extension StatusView.ViewModel { .map { timestampText, longTimestamp in "\(timestampText). \(longTimestamp)" } - + Publishers.CombineLatest4( $header, $authorName, @@ -798,7 +798,7 @@ extension StatusView.ViewModel { } .assign(to: \.accessibilityLabel, on: statusView.authorView) .store(in: &disposeBag) - + Publishers.CombineLatest3( $isContentReveal, $spoilerContent, @@ -814,7 +814,7 @@ extension StatusView.ViewModel { // TODO: replace with "Tap to reveal" strings.append(L10n.Common.Controls.Status.mediaContentWarning) } - + if isContentReveal { strings.append(content?.string) } @@ -837,17 +837,17 @@ extension StatusView.ViewModel { statusView.spoilerOverlayView.accessibilityLabel = contentAccessibilityLabel } .store(in: &disposeBag) - + let mediaAccessibilityLabel = $mediaViewConfigurations .map { configurations -> String? in let count = configurations.count return L10n.Plural.Count.media(count) } - + let replyLabel = $replyCount .map { [L10n.Common.Controls.Actions.reply, L10n.Plural.Count.reply($0)] } .map { $0.joined(separator: ", ") } - + let reblogLabel = Publishers.CombineLatest($isReblog, $reblogCount) .map { isReblog, reblogCount in [ @@ -856,7 +856,7 @@ extension StatusView.ViewModel { ] } .map { $0.joined(separator: ", ") } - + let favoriteLabel = Publishers.CombineLatest($isFavorite, $favoriteCount) .map { isFavorite, favoriteCount in [ @@ -865,7 +865,7 @@ extension StatusView.ViewModel { ] } .map { $0.joined(separator: ", ") } - + Publishers.CombineLatest4(replyLabel, reblogLabel, $isReblogEnabled, favoriteLabel) .map { replyLabel, reblogLabel, canReblog, favoriteLabel in let toolbar = statusView.actionToolbarContainer @@ -886,7 +886,7 @@ extension StatusView.ViewModel { } .assign(to: \.toolbarActions, on: statusView) .store(in: &disposeBag) - + let translatedFromLabel = Publishers.CombineLatest($translatedFromLanguage, $translatedUsingProvider) .map { (language, provider) -> String? in if let language { @@ -897,7 +897,7 @@ extension StatusView.ViewModel { } return nil } - + translatedFromLabel .receive(on: DispatchQueue.main) .sink { label in @@ -910,7 +910,7 @@ extension StatusView.ViewModel { } } .store(in: &disposeBag) - + Publishers.CombineLatest4( shortAuthorAccessibilityLabel, $contentAccessibilityLabel, @@ -919,11 +919,11 @@ extension StatusView.ViewModel { ) .map { author, content, translated, media in var labels: [String?] = [content, translated, media] - + if statusView.style != .notification { labels.insert(author, at: 0) } - + return labels .compactMap { $0 } .joined(separator: ", ") @@ -935,7 +935,7 @@ extension StatusView.ViewModel { statusView.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) - + Publishers.CombineLatest( $content, $isContentReveal.removeDuplicates() From bf304d14666216b123c107f41dee20a9374b18ab Mon Sep 17 00:00:00 2001 From: Grishka Date: Mon, 27 Mar 2023 22:20:04 +0300 Subject: [PATCH 06/10] Fix formatting & apply code review suggestions --- .../MastodonUI/Helper/MediaLayoutHelper.swift | 316 +++++++++--------- .../Container/MediaGridContainerView.swift | 48 ++- 2 files changed, 179 insertions(+), 185 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift index b0dfb40590..4963993eed 100644 --- a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -1,6 +1,6 @@ // // MediaLayoutHelper.swift -// +// // // Created by Grishka on 25.03.2023. // @@ -15,7 +15,7 @@ public struct MediaLayoutResult { let columnSizes: [Int] let rowSizes: [Int] let tiles: [Tile] - + public struct Tile { var colSpan: Int let rowSpan: Int @@ -26,25 +26,25 @@ public struct MediaLayoutResult { } class MediaLayoutHelper { - static let maxWidth: Float = 1000 - static let maxHeight: Float = 1777 - static let minHeight: Float = 563 - static let gap: Float = 1.5 + static let maxWidth: CGFloat = 1000 + static let maxHeight: CGFloat = 1777 + static let minHeight: CGFloat = 563 + static let gap: CGFloat = 1.5 static let maxRatio = maxWidth / maxHeight - + public static func generateMediaLayout(attachments: [MastodonAttachment]) -> MediaLayoutResult? { - if attachments.count<2 { + if attachments.count < 2 { return nil } - - var ratios: [Float] = [] + + var ratios: [CGFloat] = [] var allAreWide = true var allAreSquare = true for att in attachments { - let ratio: Float = Float(att.size.width/att.size.height) + let ratio: CGFloat = CGFloat(att.size.width / att.size.height) if ratio <= 1.2 { allAreWide = false - if ratio<0.8 { + if ratio < 0.8 { allAreSquare = false } } else { @@ -52,146 +52,146 @@ class MediaLayoutHelper { } ratios.append(ratio) } - - let avgRatio: Float = ratios.reduce(0.0, +) / Float(ratios.count) - + + let avgRatio: CGFloat = ratios.reduce(0.0, +) / CGFloat(ratios.count) + switch attachments.count { case 2: - if allAreWide && avgRatio>1.4*maxRatio && (ratios[1]-ratios[0])<0.2 { + if allAreWide && avgRatio > 1.4 * maxRatio && (ratios[1] - ratios[0]) < 0.2 { // Two wide attachments, one above the other - let h = Int(max(min(maxWidth/ratios[0], min(maxWidth/ratios[1], (maxHeight-gap)/2.0)), minHeight/2.0).rounded()) - + let h = Int(max(min(maxWidth / ratios[0], min(maxWidth / ratios[1], (maxHeight - gap) / 2.0)), minHeight / 2.0).rounded()) + return MediaLayoutResult(width: Int(maxWidth), - height: Int((Float(h)*2.0+gap).rounded()), - columnSizes: [Int(maxWidth)], - rowSizes: [h, h], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) - ]) + height: Int((CGFloat(h) * 2.0 + gap).rounded()), + columnSizes: [Int(maxWidth)], + rowSizes: [h, h], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) + ]) } else if allAreWide || allAreSquare { // Next to each other, same ratio - let w: Float = (maxWidth-gap) / 2.0 - let h: Float = max(min(w/ratios[0], min(w/ratios[1], maxHeight)), minHeight) - + let w: CGFloat = (maxWidth - gap) / 2.0 + let h: CGFloat = max(min(w / ratios[0], min(w / ratios[1], maxHeight)), minHeight) + let wInt: Int = Int(w.rounded()) let hInt: Int = Int(h.rounded()) - + return MediaLayoutResult(width: Int(maxWidth), - height: hInt, - columnSizes: [wInt, Int(maxWidth)-wInt], - rowSizes: [hInt], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) - ]) + height: hInt, + columnSizes: [wInt, Int(maxWidth) - wInt], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) } else { // Next to each other, different ratios - let w0: Float = ((maxWidth - gap) / ratios[1] / (1.0 / ratios[0] + 1.0 / ratios[1])) - let w1: Float = maxWidth - w0 - gap - let h: Float = max(min(maxHeight, min(w0/ratios[0], w1/ratios[1])), minHeight) - + let w0: CGFloat = ((maxWidth - gap) / ratios[1] / (1.0 / ratios[0] + 1.0 / ratios[1])) + let w1: CGFloat = maxWidth - w0 - gap + let h: CGFloat = max(min(maxHeight, min(w0 / ratios[0], w1 / ratios[1])), minHeight) + let w0Int = Int(w0.rounded()) let w1Int = Int(w1.rounded()) let hInt = Int(h.rounded()) - - return MediaLayoutResult(width: Int((w0+w1+gap).rounded()), - height: hInt, - columnSizes: [w0Int, w1Int], - rowSizes: [hInt], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) - ]) + + return MediaLayoutResult(width: Int((w0 + w1 + gap).rounded()), + height: hInt, + columnSizes: [w0Int, w1Int], + rowSizes: [hInt], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0) + ]) } case 3: - if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { + if ratios[0] > 1.2 * maxRatio || avgRatio > 1.5 * maxRatio || allAreWide { // One above two smaller ones - var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) - let w2: Float = (maxWidth-gap)/2.0 - var h: Float = min(maxHeight-hCover-gap, min(w2/ratios[1], w2/ratios[2])) - if hCover+h < minHeight { - let prevTotalHeight = hCover+h - hCover = minHeight*(hCover/prevTotalHeight) - h = minHeight*(h/prevTotalHeight) + var hCover: CGFloat = min(maxWidth / ratios[0], (maxHeight - gap) * 0.66) + let w2: CGFloat = (maxWidth - gap) / 2.0 + var h: CGFloat = min(maxHeight - hCover - gap, min(w2 / ratios[1], w2 / ratios[2])) + if hCover + h < minHeight { + let prevTotalHeight = hCover + h + hCover = minHeight * (hCover / prevTotalHeight) + h = minHeight * (h / prevTotalHeight) } - + return MediaLayoutResult(width: Int(maxWidth), - height: Int((hCover+h+gap).rounded()), - columnSizes: [Int(w2.rounded()), Int(maxWidth-w2.rounded())], - rowSizes: [Int(hCover.rounded()), Int(h.rounded())], - tiles: [ - MediaLayoutResult.Tile(colSpan: 2, rowSpan: 1, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) - ]) + height: Int((hCover + h + gap).rounded()), + columnSizes: [Int(w2.rounded()), Int(maxWidth - w2.rounded())], + rowSizes: [Int(hCover.rounded()), Int(h.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 2, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) } else { // One on the left, two smaller ones on the right - let height: Float = min(maxHeight, maxWidth*0.66/avgRatio) - let wCover: Float = min(height*ratios[0], (maxWidth-gap)*0.66) - let h1: Float = ratios[1]*(height-gap)/(ratios[2]+ratios[1]) - let h0: Float = height-h1-gap - let w: Float = min(maxWidth-wCover-gap, h1*ratios[2], h0*ratios[1]) - - return MediaLayoutResult(width: Int((wCover+w+gap).rounded()), - height: Int(height.rounded()), - columnSizes: [Int(wCover.rounded()), Int(w.rounded())], - rowSizes: [Int(h0.rounded()), Int(h1.rounded())], - tiles: [ - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 2, startCol: 0, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), - MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) - ]) + let height: CGFloat = min(maxHeight, maxWidth * 0.66 / avgRatio) + let wCover: CGFloat = min(height * ratios[0], (maxWidth - gap) * 0.66) + let h1: CGFloat = ratios[1] * (height - gap) / (ratios[2] + ratios[1]) + let h0: CGFloat = height - h1 - gap + let w: CGFloat = min(maxWidth - wCover - gap, h1 * ratios[2], h0 * ratios[1]) + + return MediaLayoutResult(width: Int((wCover + w + gap).rounded()), + height: Int(height.rounded()), + columnSizes: [Int(wCover.rounded()), Int(w.rounded())], + rowSizes: [Int(h0.rounded()), Int(h1.rounded())], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 2, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 1, startRow: 1) + ]) } case 4: - if ratios[0]>1.2*maxRatio || avgRatio>1.5*maxRatio || allAreWide { + if ratios[0] > 1.2 * maxRatio || avgRatio > 1.5 * maxRatio || allAreWide { // One above three smaller ones - var hCover: Float = min(maxWidth/ratios[0], (maxHeight-gap)*0.66) - var h: Float = (maxWidth-2.0*gap)/(ratios[1]+ratios[2]+ratios[3]) - let w0: Float = h*ratios[1] - let w1: Float = h*ratios[2] - h = min(maxHeight-hCover-gap, h) - if hCover+h1.1 { + var ratiosCropped: [CGFloat] = [] + if avgRatio > 1.1 { for ratio in ratios { ratiosCropped.append(max(1.0, ratio)) } @@ -200,56 +200,56 @@ class MediaLayoutHelper { ratiosCropped.append(min(1.0, ratio)) } } - - var tries: [[Int]: [Float]] = [:] - + + var tries: [[Int]: [CGFloat]] = [:] + // One line tries[[attachments.count]] = [calculateMultiThumbsHeight(ratios: ratiosCropped, width: maxWidth, margin: gap)] - + // Two lines - for firstLine in 1...cnt-1 { - tries[[firstLine, cnt-firstLine]] = [ + for firstLine in 1...cnt - 1 { + tries[[firstLine, cnt - firstLine]] = [ calculateMultiThumbsHeight(ratios: Array(ratiosCropped[..1 && (conf[0]>conf[1] || (conf.count>2 && conf[1]>conf[2])) { + let confH: CGFloat = heights.reduce(gap * CGFloat(heights.count - 1), +) + var confDiff = abs(confH - realMaxHeight) + if conf.count > 1 && (conf[0] > conf[1] || (conf.count > 2 && conf[1] > conf[2])) { confDiff *= 1.1 } - if confDiff Float { - return (width-(Float(ratios.count)-1.0)*margin)/ratios.reduce(0.0, +) + + private static func calculateMultiThumbsHeight(ratios: [CGFloat], width: CGFloat, margin: CGFloat) -> CGFloat { + return (width - (CGFloat(ratios.count) - 1.0) * margin) / ratios.reduce(0.0, +) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index 501edf9a08..667c4d186c 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -203,10 +203,7 @@ extension MediaGridContainerView { public func layout(in view: UIView, mediaViews: [MediaView]) { let count = mediaViews.count - if count<2 || count>maxCount { - assertionFailure("unexpected attachment count \(count)") - return - } + precondition(count >= 2 && count <= maxCount, "Unexpected attachment count \(count)") let layoutView = GridLayoutView() layoutView.translatesAutoresizingMaskIntoConstraints = false @@ -218,7 +215,7 @@ extension MediaGridContainerView { layoutView.prepare(layout: layout, maxSize: maxSize) let containerWidth = maxSize.width - let containerHeight = CGFloat(layoutView.getMeasuredHeight()) + let containerHeight = CGFloat(layoutView.measuredHeight) NSLayoutConstraint.activate([ view.widthAnchor.constraint(equalToConstant: containerWidth).priority(.required - 1), view.heightAnchor.constraint(equalToConstant: containerHeight).priority(.required - 1), @@ -227,41 +224,37 @@ extension MediaGridContainerView { } } -class GridLayoutView : UIView { +class GridLayoutView: UIView { private var layout: MediaLayoutResult? private(set) var measuredHeight = 0 - + private static let maxWidth = 400 private static let gap = 2 - + public func prepare(layout: MediaLayoutResult, maxSize: CGSize) { self.layout = layout - let width: Float = min(Float(maxSize.width), Float(GridLayoutView.maxWidth)) - let height: Float = (width*Float(layout.height)/MediaLayoutHelper.maxWidth) + let width: CGFloat = min(CGFloat(maxSize.width), CGFloat(GridLayoutView.maxWidth)) + let height: CGFloat = (width * CGFloat(layout.height) / MediaLayoutHelper.maxWidth) measuredHeight = Int(height.rounded()) } - - public func getMeasuredHeight() -> Int { - return measuredHeight - } - + override func layoutSubviews() { guard let layout = layout else { return } var width: Int = min(GridLayoutView.maxWidth, Int(frame.width)) let height: Int = Int(frame.height) - if layout.widthwidth { - xOffset = Int((Float(frame.width)/2.0-Float(width)/2.0).rounded()) + if Int(frame.width) > width { + xOffset = Int((CGFloat(frame.width) / 2.0 - CGFloat(width) / 2.0).rounded()) } - + for (i, view) in subviews.enumerated() { - if i>=layout.tiles.count { + if i >= layout.tiles.count { break // TODO make sure any additional subviews are only added at the end } let tile = layout.tiles[i] @@ -289,7 +282,8 @@ class GridLayoutView : UIView { let rowSpan = max(1, tile.rowSpan) - 1 let x = columnStarts[tile.startCol] let y = rowStarts[tile.startRow] - view.frame = CGRect(x: x+xOffset, y: y, width: columnEnds[tile.startCol+colSpan]-x, height: rowEnds[tile.startRow+rowSpan]-y) + view.frame = CGRect(x: x + xOffset, y: y, width: columnEnds[tile.startCol + colSpan] - x, height: rowEnds[tile.startRow + rowSpan] - y) + view.setNeedsLayout() } } } From a6cd5697c123b6da56128bdb12d8aa791788ec8d Mon Sep 17 00:00:00 2001 From: Grishka Date: Thu, 30 Mar 2023 18:57:33 +0300 Subject: [PATCH 07/10] Limit aspect ratio to 9:20 --- MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift index 4963993eed..89d5fec9b0 100644 --- a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -41,7 +41,7 @@ class MediaLayoutHelper { var allAreWide = true var allAreSquare = true for att in attachments { - let ratio: CGFloat = CGFloat(att.size.width / att.size.height) + let ratio: CGFloat = max(0.45, CGFloat(att.size.width / att.size.height)) if ratio <= 1.2 { allAreWide = false if ratio < 0.8 { From 03abd38165bf64263e11a366db0d1945b4c58044 Mon Sep 17 00:00:00 2001 From: Grishka Date: Sat, 30 Mar 2024 18:31:05 +0300 Subject: [PATCH 08/10] Fix layout breaking itself due to view reuse --- .../Container/MediaGridContainerView.swift | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index 667c4d186c..d1922e91cb 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -210,6 +210,7 @@ extension MediaGridContainerView { view.addSubview(layoutView) layoutView.pinToParent() for mediaView in mediaViews { + mediaView.translatesAutoresizingMaskIntoConstraints = true layoutView.addSubview(mediaView) } layoutView.prepare(layout: layout, maxSize: maxSize) @@ -239,6 +240,7 @@ class GridLayoutView: UIView { } override func layoutSubviews() { + super.layoutSubviews() guard let layout = layout else { return } var width: Int = min(GridLayoutView.maxWidth, Int(frame.width)) let height: Int = Int(frame.height) @@ -273,17 +275,25 @@ class GridLayoutView: UIView { xOffset = Int((CGFloat(frame.width) / 2.0 - CGFloat(width) / 2.0).rounded()) } - for (i, view) in subviews.enumerated() { - if i >= layout.tiles.count { - break // TODO make sure any additional subviews are only added at the end + var i: Int = 0 + for view in subviews { + if let mediaView = view as? MediaView { + if i >= layout.tiles.count { + break + } + let tile = layout.tiles[i] + let colSpan = max(1, tile.colSpan) - 1 + let rowSpan = max(1, tile.rowSpan) - 1 + let x = columnStarts[tile.startCol] + let y = rowStarts[tile.startRow] + mediaView.layer.removeAllAnimations() + mediaView.container.layer.removeAllAnimations() + mediaView.imageView.layer.removeAllAnimations() + mediaView.frame = CGRect(x: x + xOffset, y: y, width: columnEnds[tile.startCol + colSpan] - x, height: rowEnds[tile.startRow + rowSpan] - y) + mediaView.setNeedsLayout() + mediaView.layoutIfNeeded() + i = i + 1 } - let tile = layout.tiles[i] - let colSpan = max(1, tile.colSpan) - 1 - let rowSpan = max(1, tile.rowSpan) - 1 - let x = columnStarts[tile.startCol] - let y = rowStarts[tile.startRow] - view.frame = CGRect(x: x + xOffset, y: y, width: columnEnds[tile.startCol + colSpan] - x, height: rowEnds[tile.startRow + rowSpan] - y) - view.setNeedsLayout() } } } From a7703befe04b6fee0d61e5584064dd519ce58718 Mon Sep 17 00:00:00 2001 From: Grishka Date: Sat, 30 Mar 2024 18:37:50 +0300 Subject: [PATCH 09/10] Remove extra indents --- .../Container/MediaGridContainerView.swift | 10 ++-- .../View/Content/StatusView+ViewModel.swift | 54 +++++++++---------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift index d1922e91cb..c3baabb32a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Container/MediaGridContainerView.swift @@ -19,9 +19,9 @@ public final class MediaGridContainerView: UIView { static let sensitiveToggleButtonSize = CGSize(width: 34, height: 34) public static let maxCount = 10 - + let logger = Logger(subsystem: "MediaGridContainerView", category: "UI") - + public weak var delegate: MediaGridContainerViewDelegate? public private(set) lazy var viewModel: ViewModel = { let viewModel = ViewModel() @@ -83,7 +83,7 @@ extension MediaGridContainerView { let mediaView = _mediaViews[index] delegate?.mediaGridContainerView(self, didTapMediaView: mediaView, at: index) } - + @objc private func sensitiveToggleButtonDidPressed(_ sender: UIButton) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") delegate?.mediaGridContainerView(self, mediaSensitiveButtonDidPressed: sender) @@ -91,7 +91,7 @@ extension MediaGridContainerView { } extension MediaGridContainerView { - + public func dequeueMediaView(adaptiveLayout layout: AdaptiveLayout) -> MediaView { prepareForReuse() @@ -129,7 +129,7 @@ extension MediaGridContainerView { removeConstraints(constraints) } - + } extension MediaGridContainerView { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index fea3bfd67a..afff961356 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -24,13 +24,13 @@ extension StatusView { var disposeBag = Set() var observations = Set() public var objects = Set() - + let logger = Logger(subsystem: "StatusView", category: "ViewModel") public var context: AppContext? public var authContext: AuthContext? public var originalStatus: Status? - + // Header @Published public var header: Header = .none @@ -50,7 +50,7 @@ extension StatusView { @Published public var isCurrentlyTranslating = false @Published public var translatedFromLanguage: String? @Published public var translatedUsingProvider: String? - + @Published public var timestamp: Date? public var timestampFormatter: ((_ date: Date, _ isEdited: Bool) -> String)? @Published public var timestampText = "" @@ -79,10 +79,10 @@ extension StatusView { @Published public var voteCount = 0 @Published public var expireAt: Date? @Published public var expired: Bool = false - + // Card @Published public var card: Card? - + // Visibility @Published public var visibility: MastodonVisibility = .public @@ -103,7 +103,7 @@ extension StatusView { @Published public var replyCount: Int = 0 @Published public var reblogCount: Int = 0 @Published public var favoriteCount: Int = 0 - + @Published public var statusEdits: [StatusEdit] = [] @Published public var editedAt: Date? = nil @@ -111,10 +111,10 @@ extension StatusView { @Published public var activeFilters: [Mastodon.Entity.Filter] = [] @Published public var filterContext: Mastodon.Entity.Filter.Context? @Published public var isFiltered = false - + @Published public var groupedAccessibilityLabel = "" @Published public var contentAccessibilityLabel = "" - + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .share() @@ -306,7 +306,7 @@ extension StatusView.ViewModel { statusView.spoilerOverlayView.spoilerMetaLabel.configure(content: spoilerContent) // statusView.spoilerBannerView.label.configure(content: spoilerContent) // statusView.setSpoilerBannerViewHidden(isHidden: !isContentReveal) - + } else { statusView.spoilerOverlayView.spoilerMetaLabel.reset() // statusView.spoilerBannerView.label.reset() @@ -343,7 +343,7 @@ extension StatusView.ViewModel { statusView.contentMetaText.textView.accessibilityTraits = [.staticText] statusView.contentMetaText.textView.accessibilityElementsHidden = false statusView.contentMetaText.textView.isHidden = false - + } else { statusView.contentMetaText.reset() statusView.contentMetaText.textView.accessibilityLabel = "" @@ -358,7 +358,7 @@ extension StatusView.ViewModel { self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isContentReveal: \(isContentReveal)") } .store(in: &disposeBag) - + $isMediaSensitive .sink { isSensitive in guard isSensitive else { return } @@ -375,7 +375,7 @@ extension StatusView.ViewModel { statusView.authorView.contentSensitiveeToggleButton.setImage(image, for: .normal) } .store(in: &disposeBag) - + $isCurrentlyTranslating .receive(on: DispatchQueue.main) .sink { isTranslating in @@ -523,7 +523,7 @@ extension StatusView.ViewModel { statusView.pollVoteActivityIndicatorView.isHidden = true return } - + statusView.pollVoteButton.isHidden = isVoting statusView.pollVoteActivityIndicatorView.isHidden = !isVoting statusView.pollVoteActivityIndicatorView.startAnimating() @@ -533,7 +533,7 @@ extension StatusView.ViewModel { .assign(to: \.isEnabled, on: statusView.pollVoteButton) .store(in: &disposeBag) } - + private func bindCard(statusView: StatusView) { $card.sink { card in guard let card = card else { return } @@ -615,14 +615,14 @@ extension StatusView.ViewModel { } return formatter.string(from: timestamp) }() - + let text: String if let applicationName { text = L10n.Common.Controls.Status.postedViaApplication(dateString, applicationName) } else { text = dateString } - + statusView.statusMetricView.dateLabel.text = text } .store(in: &disposeBag) @@ -640,7 +640,7 @@ extension StatusView.ViewModel { statusView.statusMetricView.favoriteButton.detailLabel.text = count.formatted() } .store(in: &disposeBag) - + $editedAt .sink { editedAt in if let editedAt { @@ -778,7 +778,7 @@ extension StatusView.ViewModel { .map { timestampText, longTimestamp in "\(timestampText). \(longTimestamp)" } - + Publishers.CombineLatest4( $header, $authorName, @@ -814,7 +814,7 @@ extension StatusView.ViewModel { // TODO: replace with "Tap to reveal" strings.append(L10n.Common.Controls.Status.mediaContentWarning) } - + if isContentReveal { strings.append(content?.string) } @@ -843,7 +843,7 @@ extension StatusView.ViewModel { let count = configurations.count return L10n.Plural.Count.media(count) } - + let replyLabel = $replyCount .map { [L10n.Common.Controls.Actions.reply, L10n.Plural.Count.reply($0)] } .map { $0.joined(separator: ", ") } @@ -856,7 +856,7 @@ extension StatusView.ViewModel { ] } .map { $0.joined(separator: ", ") } - + let favoriteLabel = Publishers.CombineLatest($isFavorite, $favoriteCount) .map { isFavorite, favoriteCount in [ @@ -865,7 +865,7 @@ extension StatusView.ViewModel { ] } .map { $0.joined(separator: ", ") } - + Publishers.CombineLatest4(replyLabel, reblogLabel, $isReblogEnabled, favoriteLabel) .map { replyLabel, reblogLabel, canReblog, favoriteLabel in let toolbar = statusView.actionToolbarContainer @@ -886,7 +886,7 @@ extension StatusView.ViewModel { } .assign(to: \.toolbarActions, on: statusView) .store(in: &disposeBag) - + let translatedFromLabel = Publishers.CombineLatest($translatedFromLanguage, $translatedUsingProvider) .map { (language, provider) -> String? in if let language { @@ -897,7 +897,7 @@ extension StatusView.ViewModel { } return nil } - + translatedFromLabel .receive(on: DispatchQueue.main) .sink { label in @@ -919,11 +919,11 @@ extension StatusView.ViewModel { ) .map { author, content, translated, media in var labels: [String?] = [content, translated, media] - + if statusView.style != .notification { labels.insert(author, at: 0) } - + return labels .compactMap { $0 } .joined(separator: ", ") @@ -935,7 +935,7 @@ extension StatusView.ViewModel { statusView.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) - + Publishers.CombineLatest( $content, $isContentReveal.removeDuplicates() From 3ce292908429f9743a1c0a94fba01fd0ade120c3 Mon Sep 17 00:00:00 2001 From: Grishka Date: Tue, 2 Apr 2024 05:09:31 +0300 Subject: [PATCH 10/10] Improve the case of two horizontal attachments --- .../MastodonUI/Helper/MediaLayoutHelper.swift | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift index 89d5fec9b0..d8382bddd1 100644 --- a/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MediaLayoutHelper.swift @@ -57,7 +57,7 @@ class MediaLayoutHelper { switch attachments.count { case 2: - if allAreWide && avgRatio > 1.4 * maxRatio && (ratios[1] - ratios[0]) < 0.2 { + if allAreWide && avgRatio > 1.4 * maxRatio && abs(ratios[1] - ratios[0]) < 0.2 { // Two wide attachments, one above the other let h = Int(max(min(maxWidth / ratios[0], min(maxWidth / ratios[1], (maxHeight - gap) / 2.0)), minHeight / 2.0).rounded()) @@ -69,7 +69,26 @@ class MediaLayoutHelper { MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) ]) - } else if allAreWide || allAreSquare { + } else if allAreWide { + // two wide photos, one above the other, different ratios + var h0 = maxWidth / ratios[0] + var h1 = maxWidth / ratios[1] + if h0 + h1 < minHeight { + let prevTotalHeight = h0 + h1 + h0 = minHeight * (h0 / prevTotalHeight) + h1 = minHeight * (h1 / prevTotalHeight) + } + let h0Int = Int(h0.rounded()) + let h1Int = Int(h1.rounded()) + return MediaLayoutResult(width: Int(maxWidth), + height: h0Int + h1Int + Int(gap), + columnSizes: [Int(maxWidth)], + rowSizes: [h0Int, h1Int], + tiles: [ + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 0), + MediaLayoutResult.Tile(colSpan: 1, rowSpan: 1, startCol: 0, startRow: 1) + ]) + } else if allAreSquare { // Next to each other, same ratio let w: CGFloat = (maxWidth - gap) / 2.0 let h: CGFloat = max(min(w / ratios[0], min(w / ratios[1], maxHeight)), minHeight)