diff --git a/.swiftformat b/.swiftformat
index 87107295..9abea57e 100644
--- a/.swiftformat
+++ b/.swiftformat
@@ -8,7 +8,11 @@
--wrapparameters before-first # wrapArguments
--funcattributes prev-line # wrapAttributes
--typeattributes prev-line # wrapAttributes
---beforemarks typealias,struct,enum # organizeDeclarations
+--beforemarks typealias,struct # organizeDeclarations
+
+--structthreshold 70
+--classthreshold 70
+--enumthreshold 70
# Disabled
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/QuranTextKitTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/QuranTextKitTests.xcscheme
new file mode 100644
index 00000000..adb4ddee
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/QuranTextKitTests.xcscheme
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Core/SystemDependenciesFake/FileSystemFake.swift b/Core/SystemDependenciesFake/FileSystemFake.swift
index 79a9cf47..f8f30948 100644
--- a/Core/SystemDependenciesFake/FileSystemFake.swift
+++ b/Core/SystemDependenciesFake/FileSystemFake.swift
@@ -22,11 +22,6 @@ public final class FileSystemFake: FileSystem, Sendable {
var resourceValuesByURL: [URL: ResourceValuesFake] = [:]
}
- enum FileSystemError: Error {
- case noResourceValues
- case general(String)
- }
-
// MARK: Lifecycle
public init() {}
@@ -117,6 +112,13 @@ public final class FileSystemFake: FileSystem, Sendable {
files.insert(path)
}
+ // MARK: Internal
+
+ enum FileSystemError: Error {
+ case noResourceValues
+ case general(String)
+ }
+
// MARK: Private
private let state = ManagedCriticalState(State())
diff --git a/Core/Utilities/Sources/Extensions/String+Chunking.swift b/Core/Utilities/Sources/Extensions/String+Chunking.swift
new file mode 100644
index 00000000..b39b7f0d
--- /dev/null
+++ b/Core/Utilities/Sources/Extensions/String+Chunking.swift
@@ -0,0 +1,119 @@
+//
+// String+Chunking.swift
+//
+//
+// Created by Mohamed Afifi on 2023-12-31.
+//
+
+import Foundation
+
+extension String {
+ public func chunk(maxChunkSize: Int) -> [Substring] {
+ chunkRanges(maxChunkSize: maxChunkSize).map { self[$0] }
+ }
+
+ public func chunkRanges(maxChunkSize: Int) -> [Range] {
+ var chunks: [Range] = []
+ chunkText(self, range: startIndex ..< endIndex, maxChunkSize: maxChunkSize, strategy: .paragraph, chunks: &chunks)
+ return chunks
+ }
+}
+
+private func chunkText(_ text: String, range: Range, maxChunkSize: Int, strategy: ChunkingStrategy, chunks: inout [Range]) {
+ let blocks = text.split(in: range, on: strategy.enumerationOptions)
+
+ var accumlatedChunkStartIndex = range.lowerBound
+ var accumlatedBlocks = 0
+
+ func addAccumlatedChunk(to upperBound: String.Index, next: String.Index) {
+ if accumlatedBlocks > 0 && accumlatedChunkStartIndex < range.upperBound {
+ chunks.append(accumlatedChunkStartIndex ..< upperBound)
+ accumlatedBlocks = 0
+ }
+ accumlatedChunkStartIndex = next
+ }
+
+ for block in blocks {
+ let blockLength = text.distance(from: block.lowerBound, to: block.upperBound)
+ if blockLength > maxChunkSize {
+ // Add accumlated chunks.
+ addAccumlatedChunk(to: block.lowerBound, next: block.upperBound)
+
+ if let nextStrategy = strategy.nextStrategy() {
+ // Try a finer strategy
+ chunkText(text, range: block, maxChunkSize: maxChunkSize, strategy: nextStrategy, chunks: &chunks)
+ } else {
+ // No finer strategy, add the long block as a separate chunk.
+ chunks.append(block)
+ }
+ } else {
+ // Try to extend current chunk.
+ let extendedCurrentChunkLength = text.distance(from: accumlatedChunkStartIndex, to: block.upperBound)
+
+ if extendedCurrentChunkLength > maxChunkSize {
+ // Add the current chunk and start a new one from the current block.
+ addAccumlatedChunk(to: block.lowerBound, next: block.lowerBound)
+ accumlatedBlocks = 1
+ } else {
+ // Continue to accumlate blocks.
+ accumlatedBlocks += 1
+ }
+ }
+ }
+
+ if accumlatedChunkStartIndex < range.upperBound {
+ addAccumlatedChunk(to: range.upperBound, next: range.upperBound)
+ }
+}
+
+private extension String {
+ func split(in range: Range, on: EnumerationOptions) -> [Range] {
+ var subranges: [Range] = []
+
+ enumerateSubstrings(in: range, options: [on, .substringNotRequired]) { _, subrange, _, _ in
+ let modifiedSubrange: Range
+ if let lastRangeIndex = subranges.indices.last {
+ // Update last range to end at the new subrange.
+ subranges[lastRangeIndex] = subranges[lastRangeIndex].lowerBound ..< subrange.lowerBound
+ modifiedSubrange = subrange
+ } else {
+ modifiedSubrange = range.lowerBound ..< subrange.upperBound
+ }
+ subranges.append(modifiedSubrange)
+ }
+
+ // Check if there's any remaining text after the last subrange
+ if let lastRangeIndex = subranges.indices.last {
+ // Merge any remaining text with the last subrange
+ subranges[lastRangeIndex] = subranges[lastRangeIndex].lowerBound ..< range.upperBound
+ }
+
+ if subranges.isEmpty {
+ subranges.append(range)
+ }
+
+ return subranges
+ }
+}
+
+private enum ChunkingStrategy {
+ case paragraph, sentence, word
+
+ // MARK: Internal
+
+ var enumerationOptions: String.EnumerationOptions {
+ switch self {
+ case .paragraph: return .byParagraphs
+ case .sentence: return .bySentences
+ case .word: return .byWords
+ }
+ }
+
+ func nextStrategy() -> ChunkingStrategy? {
+ switch self {
+ case .paragraph: return .sentence
+ case .sentence: return .word
+ case .word: return nil
+ }
+ }
+}
diff --git a/Core/Utilities/Sources/Extensions/String+Extension.swift b/Core/Utilities/Sources/Extensions/String+Extension.swift
index 16d268bb..4c8ddd60 100644
--- a/Core/Utilities/Sources/Extensions/String+Extension.swift
+++ b/Core/Utilities/Sources/Extensions/String+Extension.swift
@@ -56,6 +56,12 @@ extension String {
}
extension String {
+ public func ranges(of regex: NSRegularExpression) -> [Range] {
+ let range = NSRange(startIndex ..< endIndex, in: self)
+ let matches = regex.matches(in: self, range: range)
+ return matches.compactMap { Range($0.range, in: self) }
+ }
+
public func replacingOccurrences(matchingPattern pattern: String, replacementProvider: (String) -> String?) -> String {
let expression = try! NSRegularExpression(pattern: pattern, options: []) // swiftlint:disable:this force_try
let matches = expression.matches(in: self, options: [], range: NSRange(startIndex ..< endIndex, in: self))
@@ -66,4 +72,48 @@ extension String {
current.replaceSubrange(range, with: replacement)
}
}
+
+ public func replaceMatches(
+ of regex: NSRegularExpression,
+ replace: (Substring, Int) -> String
+ ) -> (String, [Range]) {
+ let ranges = ranges(of: regex)
+ return replacing(sortedRanges: ranges, body: replace)
+ }
+}
+
+extension String {
+ public func replacing(
+ sortedRanges: [Range],
+ body: (Substring, Int) -> String
+ ) -> (String, [Range]) {
+ var newText = self
+ var offsets = [(start: Int, length: Int, offset: Int)]()
+ var replacementIndex = sortedRanges.count - 1
+
+ for matchRange in sortedRanges.reversed() {
+ let match = self[matchRange]
+
+ let replacement = body(match, replacementIndex)
+ newText.replaceSubrange(matchRange, with: replacement)
+
+ let replacementStart = newText.distance(from: newText.startIndex, to: matchRange.lowerBound)
+ offsets.append((
+ start: replacementStart,
+ length: replacement.count,
+ offset: match.count - replacement.count
+ ))
+
+ replacementIndex -= 1
+ }
+
+ var accumlatedOffset = 0
+ let ranges = offsets.reversed().map { data -> Range in
+ let start = newText.index(newText.startIndex, offsetBy: data.start - accumlatedOffset)
+ let end = newText.index(start, offsetBy: data.length)
+ accumlatedOffset += data.offset
+ return start ..< end
+ }
+ return (newText, ranges)
+ }
}
diff --git a/Core/Utilities/Tests/String+ChunkingTests.swift b/Core/Utilities/Tests/String+ChunkingTests.swift
new file mode 100644
index 00000000..2eea0067
--- /dev/null
+++ b/Core/Utilities/Tests/String+ChunkingTests.swift
@@ -0,0 +1,202 @@
+//
+// String+ChunkingTests.swift
+//
+//
+// Created by Mohamed Afifi on 2023-12-31.
+//
+
+import Foundation
+import Utilities
+import XCTest
+
+class StringChunkingTests: XCTestCase {
+ func test_shortText() {
+ let text = "Short text."
+ let chunks = text.chunkIntoStrings(maxChunkSize: 50)
+ XCTAssertEqual(chunks, [text])
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longParagraph_manySmallSentences() {
+ let text = String(repeating: "A long paragraph. ", count: 1000)
+ let chunks = text.chunkIntoStrings(maxChunkSize: 1500)
+ XCTAssertTrue(chunks.allSatisfy { $0.count <= 1500 })
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longSentence_manySmallWords() {
+ let text = String(repeating: "Morning ", count: 1500)
+ let chunks = text.chunkIntoStrings(maxChunkSize: 1500)
+ XCTAssertTrue(chunks.allSatisfy { $0.count <= 1500 })
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_separateParagraphs() {
+ let paragraphs = [
+ "First paragraph.",
+ "Second paragraph.",
+ "Third paragraph.",
+ ]
+ let expectedChunks = [
+ "First paragraph.\n",
+ "Second paragraph.\n",
+ "Third paragraph.",
+ ]
+
+ let text = paragraphs.joined(separator: "\n")
+ let maxChunkSize = (paragraphs.map(\.count).max() ?? 1) + 1
+ let chunks = text.chunkIntoStrings(maxChunkSize: maxChunkSize)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longParagraphs_singleSentence() {
+ let paragraphs = [
+ "First paragraph.",
+ "Second paragraph.",
+ "Third paragraph.",
+ ]
+ let expectedChunks = [
+ "First ", "paragraph.\n",
+ "Second ", "paragraph.\n",
+ "Third ", "paragraph.",
+ ]
+ let text = paragraphs.joined(separator: "\n")
+ let chunks = text.chunkIntoStrings(maxChunkSize: 1)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longFirstParagraph_singleSentence() {
+ let paragraphs = [
+ "Long first paragraph.",
+ "Abbreviations.",
+ "Small.",
+ ]
+ let expectedChunks = [
+ "Long first ", "paragraph.\n",
+ "Abbreviations.\n",
+ "Small.",
+ ]
+ let text = paragraphs.joined(separator: "\n")
+ let maxChunkSize = "Long first ".count
+ let chunks = text.chunkIntoStrings(maxChunkSize: maxChunkSize)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longMiddlParagraph_singleSentence() {
+ let paragraphs = [
+ "Abbreviations.",
+ "Long first paragraph.",
+ "Small.",
+ ]
+ let expectedChunks = [
+ "Abbreviations.\n",
+ "Long first ", "paragraph.\n",
+ "Small.",
+ ]
+ let text = paragraphs.joined(separator: "\n")
+ let maxChunkSize = "Long first ".count
+ let chunks = text.chunkIntoStrings(maxChunkSize: maxChunkSize)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longLastParagraph_singleSentence() {
+ let paragraphs = [
+ "Long first paragraph.",
+ "Abbreviations.",
+ "Small.",
+ ]
+ let expectedChunks = [
+ "Long first ", "paragraph. ",
+ "Abbreviations. ",
+ "Small.",
+ ]
+ let text = paragraphs.joined(separator: " ")
+ let maxChunkSize = "Long first ".count
+ let chunks = text.chunkIntoStrings(maxChunkSize: maxChunkSize)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_emptyParagraphs() {
+ let paragraphs = [
+ "",
+ "Word.",
+ "",
+ " ",
+ "Something.",
+ "",
+ ]
+ let expectedChunks = [
+ "\n",
+ "Word.\n",
+ "\n",
+ " \n",
+ "Something.\n",
+ ]
+ let text = paragraphs.joined(separator: "\n")
+ let chunks = text.chunkIntoStrings(maxChunkSize: 1)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longFirstSentence() {
+ let text = "Abbreviations. Long first paragraph. Small."
+ let expectedChunks = [
+ "Abbreviations. ",
+ "Long first ", "paragraph. ",
+ "Small.",
+ ]
+ let maxChunkSize = "Long first ".count
+ let chunks = text.chunkIntoStrings(maxChunkSize: maxChunkSize)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_emptySentences() {
+ let text = ". Word. . . Something. .."
+ let expectedChunks = [
+ ". ",
+ "Word. . . ",
+ "Something. ..",
+ ]
+ let chunks = text.chunkIntoStrings(maxChunkSize: 1)
+
+ XCTAssertEqual(chunks, expectedChunks)
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_longWord_noChunking() {
+ let text = String(repeating: "a", count: 1600)
+ let chunks = text.chunkIntoStrings(maxChunkSize: 1500)
+ XCTAssertEqual(chunks, [text])
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+
+ func test_exactChunkSize() {
+ let charCount = 5
+ let wordsCount = 1000
+ let chunkSize = charCount * wordsCount
+ let word = String(repeating: "a", count: charCount - 1) + " "
+ let text = String(repeating: word, count: wordsCount)
+ let chunks = text.chunkIntoStrings(maxChunkSize: chunkSize)
+ XCTAssertTrue(chunks.allSatisfy { $0.count == chunkSize })
+ XCTAssertEqual(chunks.joined(separator: ""), text)
+ }
+}
+
+private extension String {
+ func chunkIntoStrings(maxChunkSize: Int) -> [String] {
+ chunk(maxChunkSize: maxChunkSize).map { String($0) }
+ }
+}
diff --git a/Core/Utilities/Tests/String+ExtensionTests.swift b/Core/Utilities/Tests/String+ExtensionTests.swift
new file mode 100644
index 00000000..521d81fc
--- /dev/null
+++ b/Core/Utilities/Tests/String+ExtensionTests.swift
@@ -0,0 +1,67 @@
+//
+// String+ExtensionTests.swift
+//
+//
+// Created by Mohamed Afifi on 2024-01-01.
+//
+
+import Foundation
+import Utilities
+import XCTest
+
+class StringExtensionTests: XCTestCase {
+ let regex = try! NSRegularExpression(pattern: #"\{\{.*?\}\}"#)
+
+ var incrementReplace: (Substring, Int) -> String {
+ { _, index in "(\(index + 1))" }
+ }
+
+ func test_replaceMatches_english() {
+ let text = "Hello world{{some reference}}! Good morning{{another reference}}{{reference}}"
+ let (modifiedText, ranges) = text.replaceMatches(of: regex, replace: incrementReplace)
+
+ let expectedText = "Hello world(1)! Good morning(2)(3)"
+ XCTAssertEqual(modifiedText, expectedText)
+ assertRanges(text: modifiedText, ranges: ranges, values: ["(1)", "(2)", "(3)"])
+ }
+
+ func test_replaceMatches_emojis() {
+ let text = "Hello world{{some reference}}! ✔️ Good morning{{another reference}}{{reference}}"
+ let (modifiedText, ranges) = text.replaceMatches(of: regex, replace: incrementReplace)
+
+ let expectedText = "Hello world(1)! ✔️ Good morning(2)(3)"
+ XCTAssertEqual(modifiedText, expectedText)
+ assertRanges(text: modifiedText, ranges: ranges, values: ["(1)", "(2)", "(3)"])
+ }
+
+ func test_replaceMatches_noMatches() {
+ let text = "Hello world!"
+ let (modifiedText, ranges) = text.replaceMatches(of: regex, replace: incrementReplace)
+
+ let expectedText = "Hello world!"
+ XCTAssertEqual(modifiedText, expectedText)
+ XCTAssertEqual(ranges.count, 0)
+ }
+
+ func test_replaceMatches_emojiInMatch() {
+ let text = "{{a🛑🛑}}Hello {{🛑b🛑}}world!{{🛑🛑c🛑}}"
+ let (modifiedText, ranges) = text.replaceMatches(of: regex, replace: incrementReplace)
+
+ let expectedText = "(1)Hello (2)world!(3)"
+ XCTAssertEqual(modifiedText, expectedText)
+ assertRanges(text: modifiedText, ranges: ranges, values: ["(1)", "(2)", "(3)"])
+ }
+
+ func test_replaceMatches_onlyMatches() {
+ let text = "{{a🛑🛑}}"
+ let (modifiedText, ranges) = text.replaceMatches(of: regex, replace: incrementReplace)
+
+ let expectedText = "(1)"
+ XCTAssertEqual(modifiedText, expectedText)
+ assertRanges(text: modifiedText, ranges: ranges, values: ["(1)"])
+ }
+
+ func assertRanges(text: String, ranges: [Range], values: [String], file: StaticString = #file, line: UInt = #line) {
+ XCTAssertEqual(ranges.map { String(text[$0]) }, values, file: file, line: line)
+ }
+}
diff --git a/Domain/QuranTextKit/Sources/TranslationText/QuranTextDataService.swift b/Domain/QuranTextKit/Sources/TranslationText/QuranTextDataService.swift
index 7f748c89..6563b82d 100644
--- a/Domain/QuranTextKit/Sources/TranslationText/QuranTextDataService.swift
+++ b/Domain/QuranTextKit/Sources/TranslationText/QuranTextDataService.swift
@@ -52,6 +52,11 @@ public struct QuranTextDataService {
// MARK: Internal
+ // regex to detect quran text in translation text
+ static let quranRegex = try! NSRegularExpression(pattern: #"([«{﴿][\s\S]*?[﴾}»])"#)
+ // regex to detect footnotes in translation text
+ static let footnotesRegex = try! NSRegularExpression(pattern: #"\[\[[\s\S]*?]]"#)
+
let localTranslationRetriever: LocalTranslationsRetriever
let arabicPersistence: VerseTextPersistence
let translationsPersistenceBuilder: (Translation) -> TranslationVerseTextPersistence
@@ -59,11 +64,6 @@ public struct QuranTextDataService {
// MARK: Private
- // regex to detect quran text in translation text
- private static let quranRegex = try! NSRegularExpression(pattern: #"([«{﴿][\s\S]*?[﴾}»])"#)
- // regex to detect footer notes in translation text
- private static let footerRegex = try! NSRegularExpression(pattern: #"\[\[[\s\S]*?]]"#)
-
private func textForVerses(
_ verses: [AyahNumber],
translations: @escaping @Sendable () async throws -> [Translation]
@@ -146,6 +146,7 @@ public struct QuranTextDataService {
var verseTextList: [TranslationText] = []
do {
let versesText = try await translationPersistence.textForVerses(verses)
+ // TODO: Use TaskGroup.
for verse in verses {
let text = versesText[verse] ?? .string(l("error.translation.text-not-available"))
verseTextList.append(translationText(text))
@@ -157,7 +158,7 @@ public struct QuranTextDataService {
)
let errorText = l("error.translation.text-retrieval")
for _ in verses {
- verseTextList.append(.string(TranslationString(text: errorText, quranRanges: [], footerRanges: [])))
+ verseTextList.append(.string(TranslationString(text: errorText, quranRanges: [], footnoteRanges: [], footnotes: [])))
}
}
return (translation, verseTextList)
@@ -172,15 +173,16 @@ public struct QuranTextDataService {
}
}
- private func translationString(_ string: String) -> TranslationString {
- let range = NSRange(string.startIndex ..< string.endIndex, in: string)
- let quranRanges = ranges(of: Self.quranRegex, in: string, range: range)
- let footerRanges = ranges(of: Self.footerRegex, in: string, range: range)
- return TranslationString(text: string, quranRanges: quranRanges, footerRanges: footerRanges)
- }
+ private func translationString(_ originalString: String) -> TranslationString {
+ let footnoteTextRanges = originalString.ranges(of: Self.footnotesRegex)
+ let footnotes = footnoteTextRanges.map { originalString[$0] }
+ let (string, footnoteRanges) = originalString.replacing(
+ sortedRanges: footnoteTextRanges)
+ { _, index -> String in
+ NumberFormatter.shared.format(index + 1)
+ }
- private func ranges(of regex: NSRegularExpression, in string: String, range: NSRange) -> [NSRange] {
- let matches = regex.matches(in: string, options: [], range: range)
- return matches.map(\.range)
+ let quranRanges = string.ranges(of: Self.quranRegex)
+ return TranslationString(text: string, quranRanges: quranRanges, footnoteRanges: footnoteRanges, footnotes: footnotes)
}
}
diff --git a/Domain/QuranTextKit/Tests/QuranTextDataServiceTests.swift b/Domain/QuranTextKit/Tests/QuranTextDataServiceTests.swift
index 6c25c993..b5bf3b1e 100644
--- a/Domain/QuranTextKit/Tests/QuranTextDataServiceTests.swift
+++ b/Domain/QuranTextKit/Tests/QuranTextDataServiceTests.swift
@@ -77,7 +77,7 @@ final class QuranTextDataServiceTests: XCTestCase {
VerseText(
arabicText: TestData.quranTextAt(verse),
translations: translations.map {
- .string(TranslationString(text: TestData.translationTextAt($0, verse), quranRanges: [], footerRanges: []))
+ .string(TranslationString(text: TestData.translationTextAt($0, verse), quranRanges: [], footnoteRanges: [], footnotes: []))
},
arabicPrefix: [],
arabicSuffix: []
@@ -96,10 +96,21 @@ final class QuranTextDataServiceTests: XCTestCase {
let versesText = try await textService.textForVerses([verse])
let translationText = TestData.translationTextAt(translations[0], verse)
+ let textWithoutFootnotes = "Guide us to the Straight Way. {ABC} 1 {DE} 2 FG"
let string = TranslationString(
- text: translationText,
- quranRanges: [translationText.nsRange(of: "{ABC}"), translationText.nsRange(of: "{DE}")],
- footerRanges: [translationText.nsRange(of: "[[Footer1]]"), translationText.nsRange(of: "[[Footer2]]")]
+ text: textWithoutFootnotes,
+ quranRanges: [
+ textWithoutFootnotes.range(of: "{ABC}"),
+ textWithoutFootnotes.range(of: "{DE}"),
+ ].compactMap { $0 },
+ footnoteRanges: [
+ textWithoutFootnotes.range(of: "1"),
+ textWithoutFootnotes.range(of: "2"),
+ ].compactMap { $0 },
+ footnotes: [
+ translationText.range(of: "[[Footer1]]"),
+ translationText.range(of: "[[Footer2]]"),
+ ].compactMap { $0 }.map { translationText[$0] }
)
let expectedVerse = VerseText(
arabicText: TestData.quranTextAt(verse),
@@ -122,9 +133,3 @@ final class QuranTextDataServiceTests: XCTestCase {
TestData.sahihTranslation,
]
}
-
-extension String {
- func nsRange(of substring: String) -> NSRange {
- (self as NSString).range(of: substring)
- }
-}
diff --git a/Domain/ReadingService/Sources/ReadingResourcesService.swift b/Domain/ReadingService/Sources/ReadingResourcesService.swift
index 9df7597e..950073ad 100644
--- a/Domain/ReadingService/Sources/ReadingResourcesService.swift
+++ b/Domain/ReadingService/Sources/ReadingResourcesService.swift
@@ -16,12 +16,6 @@ import Utilities
import VLogging
public actor ReadingResourcesService {
- public enum ResourceStatus: Equatable {
- case downloading(progress: Double)
- case ready
- case error(NSError)
- }
-
// MARK: Lifecycle
public init(
@@ -44,6 +38,12 @@ public actor ReadingResourcesService {
// MARK: Public
+ public enum ResourceStatus: Equatable {
+ case downloading(progress: Double)
+ case ready
+ case error(NSError)
+ }
+
public nonisolated var publisher: AnyPublisher {
subject
// It helps slow down download progress a little, otherwise the UI may keep rendering progress after the download completes.
diff --git a/Features/QuranTranslationFeature/QuranTranslationDiffableDataSource.swift b/Features/QuranTranslationFeature/QuranTranslationDiffableDataSource.swift
index eca20d8b..3782501b 100644
--- a/Features/QuranTranslationFeature/QuranTranslationDiffableDataSource.swift
+++ b/Features/QuranTranslationFeature/QuranTranslationDiffableDataSource.swift
@@ -33,21 +33,6 @@ public class QuranTranslationDiffableDataSource {
let translation: Translation
}
- public enum ItemId: Hashable, Sendable {
- case header(AyahNumber)
- case footer(AyahNumber)
- case separator(AyahNumber)
- case suraName(AyahNumber)
- case arabic(AyahNumber, text: String, alignment: NSTextAlignment)
- case translation(TranslationId)
- }
-
- private enum Section: Hashable {
- case header
- case footer
- case verse(AyahNumber)
- }
-
// MARK: Lifecycle
// MARK: - Configuration
@@ -65,6 +50,15 @@ public class QuranTranslationDiffableDataSource {
// MARK: Public
+ public enum ItemId: Hashable, Sendable {
+ case header(AyahNumber)
+ case footer(AyahNumber)
+ case separator(AyahNumber)
+ case suraName(AyahNumber)
+ case arabic(AyahNumber, text: String, alignment: NSTextAlignment)
+ case translation(TranslationId)
+ }
+
// MARK: - Collection View
public static func translationCollectionView() -> UICollectionView {
@@ -118,6 +112,12 @@ public class QuranTranslationDiffableDataSource {
// MARK: Private
+ private enum Section: Hashable {
+ case header
+ case footer
+ case verse(AyahNumber)
+ }
+
private class NoSafeAreaCollectionView: UICollectionView {
override var safeAreaInsets: UIEdgeInsets {
.zero
diff --git a/Features/QuranTranslationFeature/TranslatedPage.swift b/Features/QuranTranslationFeature/TranslatedPage.swift
index 57afd6e8..7c748792 100644
--- a/Features/QuranTranslationFeature/TranslatedPage.swift
+++ b/Features/QuranTranslationFeature/TranslatedPage.swift
@@ -33,6 +33,7 @@ public struct TranslatedVerse: Equatable {
let translations: Translations
}
+// TODO: Remove
public class Translations: Equatable, CustomStringConvertible {
// MARK: Lifecycle
diff --git a/Features/QuranTranslationFeature/Translation+UI.swift b/Features/QuranTranslationFeature/Translation+UI.swift
index 08c238d0..af821e7b 100644
--- a/Features/QuranTranslationFeature/Translation+UI.swift
+++ b/Features/QuranTranslationFeature/Translation+UI.swift
@@ -11,6 +11,7 @@ import QuranText
import UIKit
extension Translation {
+ // TODO: Use SwiftUI.Font
func preferredTextFont(ofSize size: FontSize, factor: CGFloat = 1) -> UIFont {
if languageCode == "am" {
return .amharicTranslation(ofSize: size, factor: factor)
@@ -21,6 +22,7 @@ extension Translation {
}
}
+ // TODO: Use SwiftUI.Font
func preferredTranslatorNameFont(ofSize size: FontSize) -> UIFont {
if languageCode == "am" {
return .translatorNameAmharic(ofSize: size)
diff --git a/Features/QuranTranslationFeature/TranslationCellProvider.swift b/Features/QuranTranslationFeature/TranslationCellProvider.swift
index fa8ef706..f7a0ebc0 100644
--- a/Features/QuranTranslationFeature/TranslationCellProvider.swift
+++ b/Features/QuranTranslationFeature/TranslationCellProvider.swift
@@ -174,7 +174,7 @@ public class TranslationCellProvider {
private func translationTextToString(_ translationText: TranslationText) -> TranslationString {
switch translationText {
case .reference(let verse):
- return TranslationString(text: lFormat("translation.text.see-referenced-verse", verse.ayah), quranRanges: [], footerRanges: [])
+ return TranslationString(text: lFormat("translation.text.see-referenced-verse", verse.ayah), quranRanges: [], footnoteRanges: [], footnotes: [])
case .string(let string):
return string
}
diff --git a/Features/QuranTranslationFeature/cells/QuranTranslationTextCollectionViewCell.swift b/Features/QuranTranslationFeature/cells/QuranTranslationTextCollectionViewCell.swift
index ab66cee8..a0f89f8c 100644
--- a/Features/QuranTranslationFeature/cells/QuranTranslationTextCollectionViewCell.swift
+++ b/Features/QuranTranslationFeature/cells/QuranTranslationTextCollectionViewCell.swift
@@ -210,17 +210,17 @@ class QuranTranslationTextCollectionViewCell: QuranTranslationItemCollectionView
]
let attributedString = NSMutableAttributedString(string: text.text, attributes: attributes)
- for range in text.footerRanges {
+ for range in text.footnoteRanges {
attributedString.addAttributes([
.font: footerFont,
.foregroundColor: footerColor,
- ], range: range)
+ ], range: NSRange(range, in: text.text))
}
for range in text.quranRanges {
attributedString.addAttributes([
.foregroundColor: quranColor,
- ], range: range)
+ ], range: NSRange(range, in: text.text))
}
// add verse number
diff --git a/Features/WordPointerFeature/WordPointerViewController.swift b/Features/WordPointerFeature/WordPointerViewController.swift
index 960c263d..75849af2 100644
--- a/Features/WordPointerFeature/WordPointerViewController.swift
+++ b/Features/WordPointerFeature/WordPointerViewController.swift
@@ -13,12 +13,6 @@ import UIx
import VLogging
public final class WordPointerViewController: UIViewController {
- private enum GestureState {
- case began
- case changed(translation: CGPoint)
- case ended(velocity: CGPoint)
- }
-
// MARK: Lifecycle
init(viewModel: WordPointerViewModel) {
@@ -126,6 +120,12 @@ public final class WordPointerViewController: UIViewController {
// MARK: Private
+ private enum GestureState {
+ case began
+ case changed(translation: CGPoint)
+ case ended(velocity: CGPoint)
+ }
+
private let viewModel: WordPointerViewModel
// For word translation
diff --git a/Model/QuranText/TranslatedVerses.swift b/Model/QuranText/TranslatedVerses.swift
index c574ed44..e5d54710 100644
--- a/Model/QuranText/TranslatedVerses.swift
+++ b/Model/QuranText/TranslatedVerses.swift
@@ -21,23 +21,25 @@
import Foundation
import QuranKit
-public struct TranslationString: Equatable {
+public struct TranslationString: Hashable {
// MARK: Lifecycle
- public init(text: String, quranRanges: [NSRange], footerRanges: [NSRange]) {
+ public init(text: String, quranRanges: [Range], footnoteRanges: [Range], footnotes: [Substring]) {
self.text = text
- self.footerRanges = footerRanges
self.quranRanges = quranRanges
+ self.footnoteRanges = footnoteRanges
+ self.footnotes = footnotes
}
// MARK: Public
public let text: String
- public let quranRanges: [NSRange]
- public let footerRanges: [NSRange]
+ public let quranRanges: [Range]
+ public let footnoteRanges: [Range]
+ public let footnotes: [Substring]
}
-public enum TranslationText: Equatable {
+public enum TranslationText: Hashable {
case string(TranslationString)
case reference(AyahNumber)
}
diff --git a/UI/NoorUI/Components/List/NoorListItem.swift b/UI/NoorUI/Components/List/NoorListItem.swift
index ca295c62..704e6a53 100644
--- a/UI/NoorUI/Components/List/NoorListItem.swift
+++ b/UI/NoorUI/Components/List/NoorListItem.swift
@@ -26,11 +26,6 @@ public struct NoorListItem: View {
let location: SubtitleLocation
}
- public enum SubtitleLocation {
- case trailing
- case bottom
- }
-
public struct ItemImage {
// MARK: Lifecycle
@@ -45,24 +40,6 @@ public struct NoorListItem: View {
let color: Color?
}
- public enum Accessory {
- case text(String)
- case disclosureIndicator
- case download(DownloadType, action: AsyncAction)
- case image(NoorSystemImage, color: Color? = nil)
-
- // MARK: Internal
-
- var actionable: Bool {
- switch self {
- case .text: return false
- case .download: return true
- case .disclosureIndicator: return false
- case .image: return false
- }
- }
- }
-
// MARK: Lifecycle
public init(
@@ -91,6 +68,29 @@ public struct NoorListItem: View {
// MARK: Public
+ public enum SubtitleLocation {
+ case trailing
+ case bottom
+ }
+
+ public enum Accessory {
+ case text(String)
+ case disclosureIndicator
+ case download(DownloadType, action: AsyncAction)
+ case image(NoorSystemImage, color: Color? = nil)
+
+ // MARK: Internal
+
+ var actionable: Bool {
+ switch self {
+ case .text: return false
+ case .download: return true
+ case .disclosureIndicator: return false
+ case .image: return false
+ }
+ }
+ }
+
public var body: some View {
if let action {
if let accessory, accessory.actionable {
diff --git a/UI/NoorUI/Components/MultipartText.swift b/UI/NoorUI/Components/MultipartText.swift
index 56e75d29..5d9794eb 100644
--- a/UI/NoorUI/Components/MultipartText.swift
+++ b/UI/NoorUI/Components/MultipartText.swift
@@ -97,34 +97,6 @@ private enum TextPart {
}
public struct MultipartText: ExpressibleByStringInterpolation {
- enum FontSize {
- case body
- case caption
-
- // MARK: Internal
-
- var plainFont: Font {
- switch self {
- case .body: return .body
- case .caption: return .caption
- }
- }
-
- var suraFont: Font {
- switch self {
- case .body: return .custom(.suraNames, size: 20, relativeTo: .body)
- case .caption: return .custom(.suraNames, size: 17, relativeTo: .caption)
- }
- }
-
- var verseFont: Font {
- switch self {
- case .body: return .custom(.quran, size: 20, relativeTo: .body)
- case .caption: return .custom(.quran, size: 17, relativeTo: .caption)
- }
- }
- }
-
public struct StringInterpolation: StringInterpolationProtocol {
// MARK: Lifecycle
@@ -169,6 +141,34 @@ public struct MultipartText: ExpressibleByStringInterpolation {
// MARK: Internal
+ enum FontSize {
+ case body
+ case caption
+
+ // MARK: Internal
+
+ var plainFont: Font {
+ switch self {
+ case .body: return .body
+ case .caption: return .caption
+ }
+ }
+
+ var suraFont: Font {
+ switch self {
+ case .body: return .custom(.suraNames, size: 20, relativeTo: .body)
+ case .caption: return .custom(.suraNames, size: 17, relativeTo: .caption)
+ }
+ }
+
+ var verseFont: Font {
+ switch self {
+ case .body: return .custom(.quran, size: 20, relativeTo: .body)
+ case .caption: return .custom(.quran, size: 17, relativeTo: .caption)
+ }
+ }
+ }
+
var rawValue: String {
parts.map(\.rawValue).joined()
}
diff --git a/UI/NoorUI/Miscellaneous/ContentDimension.swift b/UI/NoorUI/Miscellaneous/ContentDimension.swift
index e2ee34a1..711d349d 100644
--- a/UI/NoorUI/Miscellaneous/ContentDimension.swift
+++ b/UI/NoorUI/Miscellaneous/ContentDimension.swift
@@ -6,13 +6,12 @@
// Copyright © 2020 Quran.com. All rights reserved.
//
-import UIKit
+import SwiftUI
public enum ContentDimension {
// MARK: Public
- public static let interSpacing: CGFloat = 8
-
+ public static let interSpacing: CGFloat = spacing
public static let interPageSpacing: CGFloat = 12
public static func insets(of view: UIView) -> NSDirectionalEdgeInsets {
@@ -26,6 +25,15 @@ public enum ContentDimension {
)
}
+ public static func readableInsets(of safeAreaInsets: EdgeInsets) -> EdgeInsets {
+ EdgeInsets(
+ top: max(24, safeAreaInsets.top) + spacing,
+ leading: safeAreaInsets.leading + spacing,
+ bottom: safeAreaInsets.bottom + spacing,
+ trailing: safeAreaInsets.trailing + spacing
+ )
+ }
+
// MARK: Internal
static let spacing: CGFloat = 8
diff --git a/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift b/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift
new file mode 100644
index 00000000..22a96b7d
--- /dev/null
+++ b/UI/UIx/SwiftUI/Epoxy/DataIDProviding.swift
@@ -0,0 +1,17 @@
+// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112
+
+// Created by eric_horacek on 12/1/20.
+// Copyright © 2020 Airbnb Inc. All rights reserved.
+
+// MARK: - DefaultDataID
+
+/// The default data ID when none is provided.
+public enum DefaultDataID: Hashable, CustomDebugStringConvertible {
+ case noneProvided
+
+ // MARK: Public
+
+ public var debugDescription: String {
+ "DefaultDataID.noneProvided"
+ }
+}
diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift b/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift
new file mode 100644
index 00000000..95197bd9
--- /dev/null
+++ b/UI/UIx/SwiftUI/Epoxy/EpoxyIntrinsicContentSizeInvalidator.swift
@@ -0,0 +1,49 @@
+// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112
+
+// Created by matthew_cheok on 11/19/21.
+// Copyright © 2021 Airbnb Inc. All rights reserved.
+
+import SwiftUI
+
+// MARK: - EpoxyIntrinsicContentSizeInvalidator
+
+/// Allows the SwiftUI view contained in an Epoxy model to request the invalidation of
+/// the container's intrinsic content size.
+///
+/// ```
+/// @Environment(\.epoxyIntrinsicContentSizeInvalidator) var invalidateIntrinsicContentSize
+///
+/// var body: some View {
+/// ...
+/// .onChange(of: size) {
+/// invalidateIntrinsicContentSize()
+/// }
+/// }
+/// ```
+public struct EpoxyIntrinsicContentSizeInvalidator {
+ // MARK: Public
+
+ public func callAsFunction() {
+ invalidate()
+ }
+
+ // MARK: Internal
+
+ let invalidate: () -> Void
+}
+
+// MARK: - EnvironmentValues
+
+extension EnvironmentValues {
+ /// A means of invalidating the intrinsic content size of the parent `EpoxySwiftUIHostingView`.
+ public var epoxyIntrinsicContentSizeInvalidator: EpoxyIntrinsicContentSizeInvalidator {
+ get { self[EpoxyIntrinsicContentSizeInvalidatorKey.self] }
+ set { self[EpoxyIntrinsicContentSizeInvalidatorKey.self] = newValue }
+ }
+}
+
+// MARK: - EpoxyIntrinsicContentSizeInvalidatorKey
+
+private struct EpoxyIntrinsicContentSizeInvalidatorKey: EnvironmentKey {
+ static let defaultValue = EpoxyIntrinsicContentSizeInvalidator(invalidate: { })
+}
diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift
new file mode 100644
index 00000000..82381928
--- /dev/null
+++ b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingController.swift
@@ -0,0 +1,55 @@
+// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112
+
+// Created by eric_horacek on 10/8/21.
+// Copyright © 2021 Airbnb Inc. All rights reserved.
+
+import SwiftUI
+
+#if !os(macOS)
+
+ // MARK: - EpoxySwiftUIUIHostingController
+
+ /// A `UIHostingController` that hosts SwiftUI views within an Epoxy container, e.g. an Epoxy
+ /// `CollectionView`.
+ ///
+ /// Exposed publicly to allow consumers to reason about these view controllers, e.g. to opt
+ /// collection view cells out of automated view controller impression tracking.
+ ///
+ /// - SeeAlso: `EpoxySwiftUIHostingView`
+ open class EpoxySwiftUIHostingController: UIHostingController {
+ // MARK: Lifecycle
+
+ /// Creates a `UIHostingController` that optionally ignores the `safeAreaInsets` when laying out
+ /// its contained `RootView`.
+ public convenience init(rootView: Content, ignoreSafeArea: Bool) {
+ self.init(rootView: rootView)
+
+ clearBackground()
+
+ // We unfortunately need to call a private API to disable the safe area. We can also accomplish
+ // this by dynamically subclassing this view controller's view at runtime and overriding its
+ // `safeAreaInsets` property and returning `.zero`. An implementation of that logic is
+ // available in this file in the `2d28b3181cca50b89618b54836f7a9b6e36ea78e` commit if this API
+ // no longer functions in future SwiftUI versions.
+ _disableSafeArea = ignoreSafeArea
+ }
+
+ // MARK: Open
+
+ override open func viewDidLoad() {
+ super.viewDidLoad()
+
+ clearBackground()
+ }
+
+ // MARK: Internal
+
+ func clearBackground() {
+ // A `UIHostingController` has a system background color by default as it's typically used in
+ // full-screen use cases. Since we're using this view controller to place SwiftUI views within
+ // other view controllers we default the background color to clear so we can see the views
+ // below, e.g. to draw highlight states in a `CollectionView`.
+ view.backgroundColor = .clear
+ }
+ }
+#endif
diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift
new file mode 100644
index 00000000..01e3f768
--- /dev/null
+++ b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUIHostingView.swift
@@ -0,0 +1,423 @@
+// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112
+
+// Created by eric_horacek on 9/16/21.
+// Copyright © 2021 Airbnb Inc. All rights reserved.
+
+import Combine
+import SwiftUI
+
+#if !os(macOS)
+
+ // MARK: - SwiftUIHostingViewReuseBehavior
+
+ /// The reuse behavior of an `EpoxySwiftUIHostingView`.
+ public enum SwiftUIHostingViewReuseBehavior: Hashable {
+ /// Instances of a `EpoxySwiftUIHostingView` with `RootView`s of same type can be reused within
+ /// the Epoxy container.
+ ///
+ /// This is the default reuse behavior.
+ case reusable
+ /// Instances of a `EpoxySwiftUIHostingView` with `RootView`s of same type can only reused within
+ /// the Epoxy container when they have identical `reuseID`s.
+ case unique(reuseID: AnyHashable)
+ }
+
+ // MARK: - EpoxySwiftUIHostingView
+
+ /// A `UIView` that hosts a SwiftUI view within an Epoxy container, e.g. an Epoxy `CollectionView`.
+ ///
+ /// Wraps an `EpoxySwiftUIHostingController` and adds it as a child view controller to the next
+ /// ancestor view controller in the hierarchy.
+ ///
+ /// There's a private API that accomplishes this same behavior without needing a `UIViewController`:
+ /// `_UIHostingView`, but we can't safely use it as 1) the behavior may change out from under us, 2)
+ /// the API is private and 3) the `_UIHostingView` doesn't not accept setting a new `View` instance.
+ ///
+ /// - SeeAlso: `EpoxySwiftUIHostingController`
+ public final class EpoxySwiftUIHostingView: UIView {
+ public struct Style: Hashable {
+ // MARK: Lifecycle
+
+ public init(
+ reuseBehavior: SwiftUIHostingViewReuseBehavior,
+ initialContent: Content,
+ ignoreSafeArea: Bool = true
+ ) {
+ self.reuseBehavior = reuseBehavior
+ self.initialContent = initialContent
+ self.ignoreSafeArea = ignoreSafeArea
+ }
+
+ // MARK: Public
+
+ public var reuseBehavior: SwiftUIHostingViewReuseBehavior
+ public var initialContent: Content
+ public var ignoreSafeArea: Bool
+
+ public static func == (lhs: Style, rhs: Style) -> Bool {
+ lhs.reuseBehavior == rhs.reuseBehavior
+ }
+
+ public func hash(into hasher: inout Hasher) {
+ hasher.combine(reuseBehavior)
+ }
+ }
+
+ public struct Content: Equatable {
+ // MARK: Lifecycle
+
+ public init(rootView: RootView, dataID: AnyHashable?) {
+ self.rootView = rootView
+ self.dataID = dataID
+ }
+
+ // MARK: Public
+
+ public var rootView: RootView
+ public var dataID: AnyHashable?
+
+ public static func == (_: Content, _: Content) -> Bool {
+ // The content should never be equal since we need the `rootView` to be updated on every
+ // content change.
+ false
+ }
+ }
+
+ // MARK: Lifecycle
+
+ public init(style: Style) {
+ // Ignore the safe area to ensure the view isn't laid out incorrectly when being sized while
+ // overlapping the safe area.
+ epoxyContent = EpoxyHostingContent(rootView: style.initialContent.rootView)
+ viewController = EpoxySwiftUIHostingController(
+ rootView: .init(content: epoxyContent, environment: epoxyEnvironment),
+ ignoreSafeArea: style.ignoreSafeArea
+ )
+
+ dataID = style.initialContent.dataID ?? DefaultDataID.noneProvided as AnyHashable
+
+ super.init(frame: .zero)
+
+ epoxyEnvironment.intrinsicContentSizeInvalidator = .init(invalidate: { [weak self] in
+ self?.viewController.view.invalidateIntrinsicContentSize()
+
+ // Inform the enclosing collection view that the size has changed, if we're contained in one,
+ // allowing the cell to resize.
+ //
+ // On iOS 16+, we could call `invalidateIntrinsicContentSize()` on the enclosing collection
+ // view cell instead, but that currently causes visual artifacts with `MagazineLayout`. The
+ // better long term fix is likely to switch to `UIHostingConfiguration` on iOS 16+ anyways.
+ if let enclosingCollectionView = self?.superview?.superview?.superview as? UICollectionView {
+ enclosingCollectionView.collectionViewLayout.invalidateLayout()
+ }
+ })
+ layoutMargins = .zero
+ }
+
+ @available(*, unavailable)
+ required init?(coder _: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ // MARK: Public
+
+ override public func didMoveToWindow() {
+ super.didMoveToWindow()
+
+ // Having our window set is an indicator that we should try adding our `viewController` as a
+ // child. We try this from a few other places to cover all of our bases.
+ addViewControllerIfNeededAndReady()
+ }
+
+ override public func didMoveToSuperview() {
+ super.didMoveToSuperview()
+
+ // Having our superview set is an indicator that we should try adding our `viewController` as a
+ // child. We try this from a few other places to cover all of our bases.
+ //
+ // Previously, we did not implement this function, and instead relied on `didMoveToWindow` being
+ // called to know when to attempt adding our `viewController` as a child. This resulted in a
+ // cell sizing issue, where the cell would return an estimated size. This was due to a timing
+ // issue with adding our `viewController` as a child. The order of events that caused the bug is
+ // as follows:
+ // 1. `collectionView(_:cellForItemAt:)` is called
+ // 2. An `EpoxySwiftUIHostingView` is created via `makeView()`
+ // 3. The hosting view is added as a subview of, and constrained to, the cell's `contentView`
+ // via a call to `setViewIfNeeded(view:)`
+ // 4. The hosting view's `didMoveToSuperview` function is called, but prior to this change, we
+ // did nothing in this function
+ // 5. We return from `collectionView(_:cellForItemAt:)`
+ // 6. `UICollectionView` calls the cell's `preferredLayoutAttributesFitting:` function, which
+ // returns an estimated size
+ // 7. The hosting view's `didMoveToWindow` function is called, and we finally add our
+ // `viewController` as a child
+ // 8. No additional sizing attempt is made by `UICollectionViewFlowLayout` or `MagazineLayout`
+ // (for some reason compositional layout recovers)
+ //
+ // A reliable repro case for this bug is the following setup:
+ // 1. Have a tab bar controller with two tabs - the first containing an Epoxy collection view,
+ // the second containing nothing
+ // 2. Have a reload function on the first view controller that sets one section with a few
+ // SwiftUI items (`Color.red.frame(width: 300, height: 300`).itemModel(dataID: ...)`)
+ // 3. Switch away from the tab containing the collection view
+ // 4. Call the reload function on the collection view on the tab that's no longer visible
+ // 4. Upon returning to the first tab, the collection view will contain incorrectly sized cells
+ addViewControllerIfNeededAndReady()
+ }
+
+ public func setContent(_ content: Content, animated _: Bool) {
+ // This triggers a change in the observed `EpoxyHostingContent` object and allows the
+ // propagation of the SwiftUI transaction, instead of just replacing the `rootView`.
+ epoxyContent.rootView = content.rootView
+ dataID = content.dataID ?? DefaultDataID.noneProvided as AnyHashable
+
+ // The view controller must be added to the view controller hierarchy to measure its content.
+ addViewControllerIfNeededAndReady()
+
+ // We need to layout the view to ensure it gets resized properly when cells are re-used
+ viewController.view.setNeedsLayout()
+ viewController.view.layoutIfNeeded()
+
+ // This is required to ensure that views with new content are properly resized.
+ viewController.view.invalidateIntrinsicContentSize()
+ }
+
+ override public func layoutMarginsDidChange() {
+ super.layoutMarginsDidChange()
+
+ let margins = layoutMargins
+ switch effectiveUserInterfaceLayoutDirection {
+ case .rightToLeft:
+ epoxyEnvironment.layoutMargins = .init(
+ top: margins.top,
+ leading: margins.right,
+ bottom: margins.bottom,
+ trailing: margins.left
+ )
+ case .leftToRight:
+ fallthrough
+ @unknown default:
+ epoxyEnvironment.layoutMargins = .init(
+ top: margins.top,
+ leading: margins.left,
+ bottom: margins.bottom,
+ trailing: margins.right
+ )
+ }
+
+ // Allow the layout margins update to fully propagate through to the SwiftUI View before
+ // invalidating the layout.
+ DispatchQueue.main.async {
+ self.viewController.view.invalidateIntrinsicContentSize()
+ }
+ }
+
+ public func handleWillDisplay(animated: Bool) {
+ guard state != .appeared, window != nil else { return }
+ transition(to: .appearing(animated: animated))
+ transition(to: .appeared)
+ }
+
+ public func handleDidEndDisplaying(animated: Bool) {
+ guard state != .disappeared else { return }
+ transition(to: .disappearing(animated: animated))
+ transition(to: .disappeared)
+ }
+
+ // MARK: Private
+
+ private let viewController: EpoxySwiftUIHostingController>
+ private let epoxyContent: EpoxyHostingContent
+ private let epoxyEnvironment = EpoxyHostingEnvironment()
+ private var dataID: AnyHashable
+ private var state: AppearanceState = .disappeared
+
+ /// Updates the appearance state of the `viewController`.
+ private func transition(to state: AppearanceState) {
+ guard state != self.state else { return }
+
+ // See "Handling View-Related Notifications" section for the state machine diagram.
+ // https://developer.apple.com/documentation/uikit/uiviewcontroller
+ switch (to: state, from: self.state) {
+ case (to: .appearing(let animated), from: .disappeared):
+ viewController.beginAppearanceTransition(true, animated: animated)
+ addViewControllerIfNeededAndReady()
+ case (to: .disappearing(let animated), from: .appeared):
+ viewController.beginAppearanceTransition(false, animated: animated)
+ case (to: .disappeared, from: .disappearing):
+ removeViewControllerIfNeeded()
+ case (to: .appeared, from: .appearing):
+ viewController.endAppearanceTransition()
+ case (to: .disappeared, from: .appeared):
+ viewController.beginAppearanceTransition(false, animated: true)
+ removeViewControllerIfNeeded()
+ case (to: .appeared, from: .disappearing(let animated)):
+ viewController.beginAppearanceTransition(true, animated: animated)
+ viewController.endAppearanceTransition()
+ case (to: .disappeared, from: .appearing(let animated)):
+ viewController.beginAppearanceTransition(false, animated: animated)
+ removeViewControllerIfNeeded()
+ case (to: .appeared, from: .disappeared):
+ viewController.beginAppearanceTransition(true, animated: false)
+ addViewControllerIfNeededAndReady()
+ viewController.endAppearanceTransition()
+ case (to: .appearing(let animated), from: .appeared):
+ viewController.beginAppearanceTransition(false, animated: animated)
+ viewController.beginAppearanceTransition(true, animated: animated)
+ case (to: .appearing(let animated), from: .disappearing):
+ viewController.beginAppearanceTransition(true, animated: animated)
+ case (to: .disappearing(let animated), from: .disappeared):
+ viewController.beginAppearanceTransition(true, animated: animated)
+ addViewControllerIfNeededAndReady()
+ viewController.beginAppearanceTransition(false, animated: animated)
+ case (to: .disappearing(let animated), from: .appearing):
+ viewController.beginAppearanceTransition(false, animated: animated)
+ case (to: .appearing, from: .appearing),
+ (to: .appeared, from: .appeared),
+ (to: .disappearing, from: .disappearing),
+ (to: .disappeared, from: .disappeared):
+ // This should never happen since we guard on identical states.
+ assertionFailure("Impossible state change from \(self.state) to \(state)")
+ }
+
+ self.state = state
+ }
+
+ private func addViewControllerIfNeededAndReady() {
+ guard let superview else {
+ // If our superview is nil, we're too early and have no chance of finding a view controller
+ // up the responder chain.
+ return
+ }
+
+ // This isn't great, and means that we're going to add this view controller as a child view
+ // controller of a view controller somewhere else in the hierarchy, which the author of that
+ // view controller may not be expecting. However there's not really a better pathway forward
+ // here without requiring a view controller instance to be passed all the way through, which is
+ // both burdensome and error-prone.
+ let nextViewController = superview.next(UIViewController.self)
+
+ if nextViewController == nil, window == nil {
+ // If the view controller is nil, but our window is also nil, we're a bit too early. It's
+ // possible to find a view controller up the responder chain without having a window, which is
+ // why we don't guard or assert on having a window.
+ return
+ }
+
+ guard let nextViewController else {
+ // One of the two previous early returns should have prevented us from getting here.
+ assertionFailure(
+ """
+ Unable to add a UIHostingController view, could not locate a UIViewController in the \
+ responder chain for view with ID \(dataID) of type \(RootView.self).
+ """)
+ return
+ }
+
+ guard viewController.parent !== nextViewController else { return }
+
+ // If in a different parent, we need to first remove from it before we add.
+ if viewController.parent != nil {
+ removeViewControllerIfNeeded()
+ }
+
+ addViewController(to: nextViewController)
+
+ state = .appeared
+ }
+
+ private func addViewController(to parent: UIViewController) {
+ viewController.willMove(toParent: parent)
+
+ parent.addChild(viewController)
+
+ addSubview(viewController.view)
+
+ viewController.view.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ viewController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
+ // Pining the hosting view controller to layoutMarginsGuide ensures the content respects the top safe area
+ // when installed inside a `TopBarContainer`
+ viewController.view.topAnchor.constraint(equalTo: topAnchor),
+ viewController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
+ // Pining the hosting view controller to layoutMarginsGuide ensures the content respects the bottom safe area
+ // when installed inside a `BottomBarContainer`
+ viewController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+
+ viewController.didMove(toParent: parent)
+ }
+
+ private func removeViewControllerIfNeeded() {
+ guard viewController.parent != nil else { return }
+
+ viewController.willMove(toParent: nil)
+ viewController.view.removeFromSuperview()
+ viewController.removeFromParent()
+ viewController.didMove(toParent: nil)
+ }
+ }
+
+ // MARK: - AppearanceState
+
+ /// The appearance state of a `EpoxySwiftUIHostingController` contained within a
+ /// `EpoxySwiftUIHostingView`.
+ private enum AppearanceState: Equatable {
+ case appearing(animated: Bool)
+ case appeared
+ case disappearing(animated: Bool)
+ case disappeared
+ }
+
+ // MARK: - UIResponder
+
+ extension UIResponder {
+ /// Recursively traverses the responder chain upwards from this responder to its next responder
+ /// until the a responder of the given type is located, else returns `nil`.
+ @nonobjc
+ fileprivate func next(_ type: ResponderType.Type) -> ResponderType? {
+ self as? ResponderType ?? next?.next(type)
+ }
+ }
+
+ // MARK: - EpoxyHostingContent
+
+ /// The object that is used to communicate changes in the root view to the
+ /// `EpoxySwiftUIHostingController`.
+ final class EpoxyHostingContent: ObservableObject {
+ // MARK: Lifecycle
+
+ init(rootView: RootView) {
+ _rootView = .init(wrappedValue: rootView)
+ }
+
+ // MARK: Internal
+
+ @Published var rootView: RootView
+ }
+
+ // MARK: - EpoxyHostingEnvironment
+
+ /// The object that is used to communicate values to SwiftUI views within an
+ /// `EpoxySwiftUIHostingController`, e.g. layout margins.
+ final class EpoxyHostingEnvironment: ObservableObject {
+ @Published var layoutMargins = EdgeInsets()
+ @Published var intrinsicContentSizeInvalidator = EpoxyIntrinsicContentSizeInvalidator(invalidate: { })
+ }
+
+ // MARK: - EpoxyHostingWrapper
+
+ /// The wrapper view that is used to communicate values to SwiftUI views within an
+ /// `EpoxySwiftUIHostingController`, e.g. layout margins.
+ struct EpoxyHostingWrapper: View {
+ @ObservedObject var content: EpoxyHostingContent
+ @ObservedObject var environment: EpoxyHostingEnvironment
+
+ var body: some View {
+ content.rootView
+ .environment(\.epoxyLayoutMargins, environment.layoutMargins)
+ .environment(\.epoxyIntrinsicContentSizeInvalidator, environment.intrinsicContentSizeInvalidator)
+ }
+ }
+
+#endif
diff --git a/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift
new file mode 100644
index 00000000..86c85b83
--- /dev/null
+++ b/UI/UIx/SwiftUI/Epoxy/EpoxySwiftUILayoutMargins.swift
@@ -0,0 +1,49 @@
+// From: https://github.com/airbnb/epoxy-ios/blob/ecee1ace58d58e3cc918a2dea28095de713b1112
+
+// Created by eric_horacek on 10/8/21.
+// Copyright © 2021 Airbnb Inc. All rights reserved.
+
+import SwiftUI
+
+// MARK: - View
+
+extension View {
+ /// Applies the layout margins from the parent `EpoxySwiftUIHostingView` to this `View`, if there
+ /// are any.
+ ///
+ /// Can be used to have a background in SwiftUI underlap the safe area within a bar installer, for
+ /// example.
+ ///
+ /// These margins are propagated via the `EnvironmentValues.epoxyLayoutMargins`.
+ public func epoxyLayoutMargins() -> some View {
+ modifier(EpoxyLayoutMarginsPadding())
+ }
+}
+
+// MARK: - EnvironmentValues
+
+extension EnvironmentValues {
+ /// The layout margins of the parent `EpoxySwiftUIHostingView`, else zero if there is none.
+ public var epoxyLayoutMargins: EdgeInsets {
+ get { self[EpoxyLayoutMarginsKey.self] }
+ set { self[EpoxyLayoutMarginsKey.self] = newValue }
+ }
+}
+
+// MARK: - EpoxyLayoutMarginsKey
+
+private struct EpoxyLayoutMarginsKey: EnvironmentKey {
+ static let defaultValue = EdgeInsets()
+}
+
+// MARK: - EpoxyLayoutMarginsPadding
+
+/// A view modifier that applies the layout margins from an enclosing `EpoxySwiftUIHostingView` to
+/// the modified `View`.
+private struct EpoxyLayoutMarginsPadding: ViewModifier {
+ @Environment(\.epoxyLayoutMargins) var epoxyLayoutMargins
+
+ func body(content: Content) -> some View {
+ content.padding(epoxyLayoutMargins)
+ }
+}
diff --git a/UI/UIx/SwiftUI/Miscellaneous/BackwardCompatibleTaskModifier.swift b/UI/UIx/SwiftUI/Miscellaneous/BackwardCompatibleTaskModifier.swift
index 0f4f6e30..8ea36c56 100644
--- a/UI/UIx/SwiftUI/Miscellaneous/BackwardCompatibleTaskModifier.swift
+++ b/UI/UIx/SwiftUI/Miscellaneous/BackwardCompatibleTaskModifier.swift
@@ -39,11 +39,16 @@ public struct BackwardCompatibleTaskModifier: ViewModifier {
} else {
content
.onAppear {
+ if appeared {
+ return
+ }
+ appeared = true
task = Task {
await action()
}
}
.onDisappear {
+ appeared = false
task?.cancel()
task = nil
}
@@ -55,4 +60,5 @@ public struct BackwardCompatibleTaskModifier: ViewModifier {
private var priority: TaskPriority
private var action: @Sendable () async -> Void
@State private var task: Task?
+ @State private var appeared = false
}
diff --git a/UI/UIx/UIKit/Extensions/UIViewController+Extensions.swift b/UI/UIx/UIKit/Extensions/UIViewController+Extensions.swift
index b4705c3a..b1923afb 100755
--- a/UI/UIx/UIKit/Extensions/UIViewController+Extensions.swift
+++ b/UI/UIx/UIKit/Extensions/UIViewController+Extensions.swift
@@ -15,10 +15,18 @@ public extension UIViewController {
viewController.didMove(toParent: self)
}
+ func removeSelfFromParentIfNeeded() {
+ if parent == nil {
+ return
+ }
+ willMove(toParent: nil)
+ view.removeFromSuperview()
+ removeFromParent()
+ didMove(toParent: nil)
+ }
+
func removeChild(_ viewController: UIViewController) {
- viewController.willMove(toParent: nil)
- viewController.view.removeFromSuperview()
- viewController.removeFromParent()
+ viewController.removeSelfFromParentIfNeeded()
}
func rotateToPortraitIfPhone() {
diff --git a/UI/UIx/UIKit/Miscellaneous/DirectionalEdgeInsets.swift b/UI/UIx/UIKit/Miscellaneous/DirectionalEdgeInsets.swift
index a29a7999..5d739010 100644
--- a/UI/UIx/UIKit/Miscellaneous/DirectionalEdgeInsets.swift
+++ b/UI/UIx/UIKit/Miscellaneous/DirectionalEdgeInsets.swift
@@ -20,6 +20,7 @@
import UIKit
+// TODO: Remove
public struct DirectionalEdgeInsets {
// MARK: Lifecycle