Swiftgram/submodules/MosaicLayout/Sources/ChatMessageBubbleMosaicLayout.swift
2023-04-09 23:14:31 +04:00

356 lines
17 KiB
Swift

import Foundation
import UIKit
import Display
public struct MosaicItemPosition: OptionSet {
public var rawValue: Int32
public init(rawValue: Int32) {
self.rawValue = rawValue
}
public static let top = MosaicItemPosition(rawValue: 1)
public static let bottom = MosaicItemPosition(rawValue: 2)
public static let left = MosaicItemPosition(rawValue: 4)
public static let right = MosaicItemPosition(rawValue: 8)
public static let inside = MosaicItemPosition(rawValue: 16)
public static let unknown = MosaicItemPosition(rawValue: 65536)
public var isWide: Bool {
return self.contains(.left) && self.contains(.right) && (self.contains(.top) || self.contains(.bottom))
}
}
private struct MosaicItemInfo {
let index: Int
let imageSize: CGSize
let aspectRatio: CGFloat
var layoutFrame: CGRect = CGRect()
var position: MosaicItemPosition = []
}
private struct MosaicLayoutAttempt {
let lineCounts: [Int]
let heights: [CGFloat]
}
public func chatMessageBubbleMosaicLayout(maxSize: CGSize, itemSizes: [CGSize], spacing: CGFloat = 1.0, fillWidth: Bool = false) -> ([(CGRect, MosaicItemPosition)], CGSize) {
var proportions = ""
var averageAspectRatio: CGFloat = 1.0
var forceCalc = false
var itemInfos = itemSizes.enumerated().map { index, itemSize -> MosaicItemInfo in
let aspectRatio = itemSize.height.isZero ? 1.0 : itemSize.width / itemSize.height
if aspectRatio > 1.2 {
proportions += "w"
} else if aspectRatio < 0.8 {
proportions += "n"
} else {
proportions += "q"
}
if aspectRatio > 2.0 {
forceCalc = true
}
averageAspectRatio += aspectRatio
return MosaicItemInfo(index: index, imageSize: itemSize, aspectRatio: aspectRatio, layoutFrame: CGRect(), position: [])
}
let minWidth: CGFloat = 68.0
let minHeight: CGFloat = 81.0
let maxAspectRatio = maxSize.width / maxSize.height
if !itemInfos.isEmpty {
averageAspectRatio = averageAspectRatio / CGFloat(itemInfos.count)
}
if !forceCalc {
if itemInfos.count == 2 {
if proportions == "ww" && averageAspectRatio > 1.4 * maxAspectRatio && itemInfos[1].aspectRatio - itemInfos[0].aspectRatio < 0.2 {
let width = maxSize.width
let height = floor(min(width / itemInfos[0].aspectRatio, min(width / itemInfos[1].aspectRatio, (maxSize.height - spacing) / 2.0)))
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: width, height: height)
itemInfos[0].position = [.top, .left, .right]
itemInfos[1].layoutFrame = CGRect(x: 0.0, y: height + spacing, width: width, height: height)
itemInfos[1].position = [.bottom, .left, .right]
} else if proportions == "ww" || proportions == "qq" {
let width = (maxSize.width - spacing) / 2.0
let height = floor(min(width / itemInfos[0].aspectRatio, min(width / itemInfos[1].aspectRatio, maxSize.height)))
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: width, height: height)
itemInfos[0].position = [.top, .left, .bottom]
itemInfos[1].layoutFrame = CGRect(x: width + spacing, y: 0.0, width: width, height: height)
itemInfos[1].position = [.top, .right, .bottom]
} else {
let secondWidth = floor(min(0.5 * (maxSize.width - spacing), round((maxSize.width - spacing) / itemInfos[0].aspectRatio / (1.0 / itemInfos[0].aspectRatio + 1.0 / itemInfos[1].aspectRatio))))
let firstWidth = maxSize.width - secondWidth - spacing
let height = floor(min(maxSize.height, round(min(firstWidth / itemInfos[0].aspectRatio, secondWidth / itemInfos[1].aspectRatio))))
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: firstWidth, height: height)
itemInfos[0].position = [.top, .left, .bottom]
itemInfos[1].layoutFrame = CGRect(x: firstWidth + spacing, y: 0.0, width: secondWidth, height: height)
itemInfos[1].position = [.top, .right, .bottom]
}
} else if (itemInfos.count == 3) {
if proportions.hasPrefix("n") {
let firstHeight = maxSize.height
let thirdHeight = min((maxSize.height - spacing) * 0.5, round(itemInfos[1].aspectRatio * (maxSize.width - spacing) / (itemInfos[2].aspectRatio + itemInfos[1].aspectRatio)))
let secondHeight = maxSize.height - thirdHeight - spacing
var rightWidth = max(minWidth, min((maxSize.width - spacing) * 0.5, round(min(thirdHeight * itemInfos[2].aspectRatio, secondHeight * itemInfos[1].aspectRatio))))
if fillWidth {
rightWidth = floorToScreenPixels(maxSize.width / 2.0)
}
var leftWidth = round(min(firstHeight * itemInfos[0].aspectRatio, (maxSize.width - spacing - rightWidth)))
if fillWidth {
leftWidth = maxSize.width - spacing - rightWidth
}
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: leftWidth, height: firstHeight)
itemInfos[0].position = [.top, .left, .bottom]
itemInfos[1].layoutFrame = CGRect(x: leftWidth + spacing, y: 0.0, width: rightWidth, height: secondHeight)
itemInfos[1].position = [.right, .top]
itemInfos[2].layoutFrame = CGRect(x: leftWidth + spacing, y: secondHeight + spacing, width: rightWidth, height: thirdHeight)
itemInfos[2].position = [.right, .bottom]
} else {
var width = maxSize.width
let firstHeight = floor(min(width / itemInfos[0].aspectRatio, (maxSize.height - spacing) * 0.66))
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: width, height: firstHeight)
itemInfos[0].position = [.top, .left, .right]
width = (maxSize.width - spacing) / 2.0
let secondHeight = min(maxSize.height - firstHeight - spacing, round(min(width / itemInfos[1].aspectRatio, width / itemInfos[2].aspectRatio)))
itemInfos[1].layoutFrame = CGRect(x: 0.0, y: firstHeight + spacing, width: width, height: secondHeight)
itemInfos[1].position = [.left, .bottom]
itemInfos[2].layoutFrame = CGRect(x: width + spacing, y: firstHeight + spacing, width: width, height: secondHeight)
itemInfos[2].position = [.right, .bottom]
}
} else if itemInfos.count == 4 {
if proportions == "wwww" || proportions.hasPrefix("w") {
let w = maxSize.width
let h0 = round(min(w / itemInfos[0].aspectRatio, (maxSize.height - spacing) * 0.66))
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: w, height: h0)
itemInfos[0].position = [.top, .left, .right]
var h = round((maxSize.width - 2 * spacing) / (itemInfos[1].aspectRatio + itemInfos[2].aspectRatio + itemInfos[3].aspectRatio))
let w0 = max(minWidth, min((maxSize.width - 2 * spacing) * 0.4, h * itemInfos[1].aspectRatio))
let w2 = max(max(minWidth, (maxSize.width - 2 * spacing) * 0.33), h * itemInfos[3].aspectRatio)
let w1 = w - w0 - w2 - 2 * spacing
h = max(minHeight, min(maxSize.height - h0 - spacing, h))
itemInfos[1].layoutFrame = CGRect(x: 0.0, y: h0 + spacing, width: w0, height: h)
itemInfos[1].position = [.left, .bottom]
itemInfos[2].layoutFrame = CGRect(x: w0 + spacing, y: h0 + spacing, width: w1, height: h)
itemInfos[2].position = [.bottom]
itemInfos[3].layoutFrame = CGRect(x: w0 + w1 + 2 * spacing, y: h0 + spacing, width: w2, height: h)
itemInfos[3].position = [.right, .bottom]
} else {
let h = maxSize.height
let w0 = round(min(h * itemInfos[0].aspectRatio, (maxSize.width - spacing) * 0.6))
itemInfos[0].layoutFrame = CGRect(x: 0.0, y: 0.0, width: w0, height: h)
itemInfos[0].position = [.top, .left, .bottom]
var w = round((maxSize.height - 2 * spacing) / (1.0 / itemInfos[1].aspectRatio + 1.0 / itemInfos[2].aspectRatio + 1.0 / itemInfos[3].aspectRatio))
let h0 = floor(w / itemInfos[1].aspectRatio)
let h1 = floor(w / itemInfos[2].aspectRatio)
let h2 = h - h0 - h1 - 2.0 * spacing
w = max(minWidth, min(maxSize.width - w0 - spacing, w))
itemInfos[1].layoutFrame = CGRect(x: w0 + spacing, y: 0.0, width: w, height: h0)
itemInfos[1].position = [.right, .top]
itemInfos[2].layoutFrame = CGRect(x: w0 + spacing, y: h0 + spacing, width: w, height: h1)
itemInfos[2].position = [.right]
itemInfos[3].layoutFrame = CGRect(x: w0 + spacing, y: h0 + h1 + 2 * spacing, width: w, height: h2)
itemInfos[3].position = [.right, .bottom]
}
}
}
if forceCalc || itemInfos.count >= 5 {
var croppedRatios: [CGFloat] = []
for itemInfo in itemInfos {
let aspectRatio = itemInfo.aspectRatio
var croppedRatio = aspectRatio
if averageAspectRatio > 1.1 {
croppedRatio = max(1.0, aspectRatio)
} else {
croppedRatio = min(1.0, aspectRatio)
}
croppedRatio = max(0.66667, min(1.7, croppedRatio))
croppedRatios.append(croppedRatio)
}
func multiHeight(_ ratios: [CGFloat]) -> CGFloat {
var ratioSum: CGFloat = 0.0
for ratio in ratios {
ratioSum += ratio
}
return (maxSize.width - CGFloat(ratios.count - 1) * spacing) / ratioSum
}
var attempts: [MosaicLayoutAttempt] = []
func addAttempt(_ lineCounts: [Int], _ heights: [CGFloat], _ attempts: inout [MosaicLayoutAttempt]) {
attempts.append(MosaicLayoutAttempt(lineCounts: lineCounts, heights: heights))
}
for firstLine in 1 ..< croppedRatios.count {
let secondLine = croppedRatios.count - firstLine
if firstLine > 3 || secondLine > 3 {
continue
}
addAttempt([firstLine, croppedRatios.count - firstLine], [multiHeight(Array(croppedRatios[0..<firstLine])), multiHeight(Array(croppedRatios[firstLine..<croppedRatios.count]))], &attempts)
}
for firstLine in 1 ..< croppedRatios.count - 1 {
for secondLine in 1 ..< croppedRatios.count - firstLine {
let thirdLine = croppedRatios.count - firstLine - secondLine
if firstLine > 3 || secondLine > (averageAspectRatio < 0.85 ? 4 : 3) || thirdLine > 3 {
continue
}
addAttempt([firstLine, secondLine, thirdLine], [multiHeight(Array(croppedRatios[0 ..< firstLine])), multiHeight(Array(croppedRatios[firstLine ..< croppedRatios.count - thirdLine])), multiHeight(Array(croppedRatios[firstLine + secondLine ..< croppedRatios.count]))], &attempts)
}
}
if croppedRatios.count - 2 >= 1 {
outer: for firstLine in 1 ..< croppedRatios.count - 2 {
if croppedRatios.count - firstLine < 1 {
continue outer
}
for secondLine in 1 ..< croppedRatios.count - firstLine {
for thirdLine in 1 ..< croppedRatios.count - firstLine - secondLine {
let fourthLine = croppedRatios.count - firstLine - secondLine - thirdLine
if firstLine > 3 || secondLine > 3 || thirdLine > 3 || fourthLine > 3 {
continue
}
addAttempt([firstLine, secondLine, thirdLine, fourthLine], [multiHeight(Array(croppedRatios[0 ..< firstLine])), multiHeight(Array(croppedRatios[firstLine ..< croppedRatios.count - thirdLine - fourthLine])), multiHeight(Array(croppedRatios[firstLine + secondLine ..< croppedRatios.count - fourthLine])), multiHeight(Array(croppedRatios[firstLine + secondLine + thirdLine ..< croppedRatios.count]))], &attempts)
}
}
}
}
let maxHeight = floor(maxSize.width / 3.0 * 4.0)
var optimal: MosaicLayoutAttempt? = nil
var optimalDiff: CGFloat = 0.0
for attempt in attempts {
var totalHeight = spacing * CGFloat(attempt.heights.count - 1)
var minLineHeight: CGFloat = .greatestFiniteMagnitude
var maxLineHeight: CGFloat = 0.0
for h in attempt.heights {
totalHeight += floor(h)
if totalHeight < minLineHeight {
minLineHeight = totalHeight
}
if totalHeight > maxLineHeight {
maxLineHeight = totalHeight
}
}
var diff = abs(totalHeight - maxHeight)
if attempt.lineCounts.count > 1 {
if (attempt.lineCounts[0] > attempt.lineCounts[1]) || (attempt.lineCounts.count > 2 && attempt.lineCounts[1] > attempt.lineCounts[2]) || (attempt.lineCounts.count > 3 && attempt.lineCounts[2] > attempt.lineCounts[3]) {
diff *= 1.5
}
}
if minLineHeight < minWidth {
diff *= 1.5
}
if optimal == nil || diff < optimalDiff {
optimal = attempt
optimalDiff = diff
}
}
var index = 0
var y: CGFloat = 0.0
if let optimal = optimal {
for i in 0 ..< optimal.lineCounts.count {
let count = optimal.lineCounts[i]
let lineHeight = ceil(optimal.heights[i])
var x: CGFloat = 0.0
var positionFlags: MosaicItemPosition = []
if i == 0 {
positionFlags.insert(.top)
}
if i == optimal.lineCounts.count - 1 {
positionFlags.insert(.bottom)
}
for k in 0 ..< count {
var innerPositionFlags = positionFlags
if k == 0 {
innerPositionFlags.insert(.left)
}
if k == count - 1 {
innerPositionFlags.insert(.right)
}
if positionFlags == [] {
innerPositionFlags = .inside
}
let ratio = croppedRatios[index]
let width = ceil(ratio * lineHeight)
itemInfos[index].layoutFrame = CGRect(x: x, y: y, width: width, height: lineHeight)
itemInfos[index].position = innerPositionFlags
x += width + spacing
index += 1
}
y += lineHeight + spacing
}
index = 0
var maxWidth: CGFloat = 0.0
for i in 0 ..< optimal.lineCounts.count {
let count = optimal.lineCounts[i]
for k in 0 ..< count {
if k == count - 1 {
maxWidth = max(maxWidth, itemInfos[index].layoutFrame.maxX)
}
index += 1
}
}
index = 0
for i in 0 ..< optimal.lineCounts.count {
let count = optimal.lineCounts[i]
for k in 0 ..< count {
if k == count - 1 {
var frame = itemInfos[index].layoutFrame
frame.size.width = max(frame.width, maxWidth - frame.minX)
itemInfos[index].layoutFrame = frame
}
index += 1
}
}
}
}
var dimensions = CGSize()
for itemInfo in itemInfos {
dimensions.width = max(dimensions.width, round(itemInfo.layoutFrame.maxX))
dimensions.height = max(dimensions.height, round(itemInfo.layoutFrame.maxY))
}
return (itemInfos.map { ($0.layoutFrame, $0.position) }, dimensions)
}