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