From 39792adcf9b99254f7dc9dc21a77881868623d9c Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Sat, 4 Feb 2023 15:49:11 +0100 Subject: [PATCH 1/2] Fix the default image provider. --- .../Extensions/DefaultImageProvider.swift | 120 +----------------- .../DefaultImageView/DefaultImageLoader.swift | 56 ++++++++ .../DefaultImageView/DefaultImageView.swift | 23 ++++ .../DefaultImageViewModel.swift | 33 +++++ 4 files changed, 115 insertions(+), 117 deletions(-) create mode 100644 Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageLoader.swift create mode 100644 Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageView.swift create mode 100644 Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageViewModel.swift diff --git a/Sources/MarkdownUI/Extensions/DefaultImageProvider.swift b/Sources/MarkdownUI/Extensions/DefaultImageProvider.swift index 151fd664..9bc23aa9 100644 --- a/Sources/MarkdownUI/Extensions/DefaultImageProvider.swift +++ b/Sources/MarkdownUI/Extensions/DefaultImageProvider.swift @@ -10,8 +10,9 @@ public struct DefaultImageProvider: ImageProvider { self.urlSession = urlSession } - public func makeImage(url: URL?) -> some SwiftUI.View { - View(url: url, urlSession: self.urlSession).id(url) + public func makeImage(url: URL?) -> some View { + DefaultImageView(url: url, urlSession: self.urlSession) + .id(url) } } @@ -23,118 +24,3 @@ extension ImageProvider where Self == DefaultImageProvider { .init() } } - -// MARK: - View - -extension DefaultImageProvider { - private struct View: SwiftUI.View { - @StateObject private var viewModel = ViewModel() - - let url: URL? - let urlSession: URLSession - - var body: some SwiftUI.View { - self.content.task { - await self.viewModel.onAppear(url: self.url, urlSession: self.urlSession) - } - } - - @ViewBuilder private var content: some SwiftUI.View { - switch self.viewModel.state { - case .notRequested, .loading, .failure: - Color.clear - .frame(width: 0, height: 0) - case .success(let image, let size): - ResizeToFit(idealSize: size) { - image.resizable() - } - } - } - } -} - -// MARK: - ViewModel - -extension DefaultImageProvider { - private final class ViewModel: ObservableObject { - enum State: Equatable { - case notRequested - case loading - case success(Image, CGSize) - case failure - } - - @Published private(set) var state: State = .notRequested - - private let cache = NSCache() - - @MainActor func onAppear(url: URL?, urlSession: URLSession) async { - guard case .notRequested = state else { - return - } - - guard let url = url else { - self.state = .failure - return - } - - self.state = .loading - - do { - let image = try await self.image(with: url, urlSession: urlSession) - self.state = .success(.init(platformImage: image), image.size) - } catch { - self.state = .failure - } - } - - private func image(with url: URL, urlSession: URLSession) async throws -> PlatformImage { - if let image = self.cache.object(forKey: url as NSURL) { - return image - } - - let (data, response) = try await urlSession.data(from: url) - - guard let statusCode = (response as? HTTPURLResponse)?.statusCode, - 200..<300 ~= statusCode - else { - throw URLError(.badServerResponse) - } - - guard let image = PlatformImage.decode(from: data) else { - throw URLError(.cannotDecodeContentData) - } - - self.cache.setObject(image, forKey: url as NSURL) - - return image - } - } -} - -// MARK: - PlatformImage - -extension PlatformImage { - fileprivate static func decode(from data: Data) -> PlatformImage? { - #if os(iOS) || os(tvOS) || os(watchOS) - guard let image = UIImage(data: data) else { - return nil - } - return image - #elseif os(macOS) - guard let bitmapImageRep = NSBitmapImageRep(data: data) else { - return nil - } - - let image = NSImage( - size: NSSize( - width: bitmapImageRep.pixelsWide, - height: bitmapImageRep.pixelsHigh - ) - ) - - image.addRepresentation(bitmapImageRep) - return image - #endif - } -} diff --git a/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageLoader.swift b/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageLoader.swift new file mode 100644 index 00000000..cbb464d3 --- /dev/null +++ b/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageLoader.swift @@ -0,0 +1,56 @@ +import SwiftUI + +final class DefaultImageLoader { + static let shared = DefaultImageLoader() + + private let cache = NSCache() + + private init() {} + + func image(with url: URL, urlSession: URLSession) async throws -> PlatformImage { + if let image = self.cache.object(forKey: url as NSURL) { + return image + } + + let (data, response) = try await urlSession.data(from: url) + + guard let statusCode = (response as? HTTPURLResponse)?.statusCode, + 200..<300 ~= statusCode + else { + throw URLError(.badServerResponse) + } + + guard let image = PlatformImage.decode(from: data) else { + throw URLError(.cannotDecodeContentData) + } + + self.cache.setObject(image, forKey: url as NSURL) + + return image + } +} + +extension PlatformImage { + fileprivate static func decode(from data: Data) -> PlatformImage? { + #if os(iOS) || os(tvOS) || os(watchOS) + guard let image = UIImage(data: data) else { + return nil + } + return image + #elseif os(macOS) + guard let bitmapImageRep = NSBitmapImageRep(data: data) else { + return nil + } + + let image = NSImage( + size: NSSize( + width: bitmapImageRep.pixelsWide, + height: bitmapImageRep.pixelsHigh + ) + ) + + image.addRepresentation(bitmapImageRep) + return image + #endif + } +} diff --git a/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageView.swift b/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageView.swift new file mode 100644 index 00000000..10ec2914 --- /dev/null +++ b/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct DefaultImageView: View { + @StateObject private var viewModel = DefaultImageViewModel() + + let url: URL? + let urlSession: URLSession + + var body: some View { + switch self.viewModel.state { + case .notRequested, .loading, .failure: + Color.clear + .frame(width: 0, height: 0) + .task { + await self.viewModel.task(url: self.url, urlSession: self.urlSession) + } + case .success(let image, let size): + ResizeToFit(idealSize: size) { + image.resizable() + } + } + } +} diff --git a/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageViewModel.swift b/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageViewModel.swift new file mode 100644 index 00000000..472ad331 --- /dev/null +++ b/Sources/MarkdownUI/Extensions/DefaultImageView/DefaultImageViewModel.swift @@ -0,0 +1,33 @@ +import SwiftUI + +final class DefaultImageViewModel: ObservableObject { + enum State: Equatable { + case notRequested + case loading + case success(Image, CGSize) + case failure + } + + @Published private(set) var state: State = .notRequested + private let imageLoader: DefaultImageLoader = .shared + + @MainActor func task(url: URL?, urlSession: URLSession) async { + guard case .notRequested = state else { + return + } + + guard let url = url else { + self.state = .failure + return + } + + self.state = .loading + + do { + let image = try await self.imageLoader.image(with: url, urlSession: urlSession) + self.state = .success(.init(platformImage: image), image.size) + } catch { + self.state = .failure + } + } +} From 648290408f1e5dba2632f7c92b72880633645e6a Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Sun, 5 Feb 2023 08:18:28 +0100 Subject: [PATCH 2/2] Add a sample that shows how to use Markdown with images inside a LazyVStack --- Examples/Demo/Demo.xcodeproj/project.pbxproj | 4 + .../Demo/Demo/CodeSyntaxHighlightView.swift | 1 - Examples/Demo/Demo/CodeView.swift | 1 - Examples/Demo/Demo/ContentView.swift | 29 ++++++ Examples/Demo/Demo/DingusView.swift | 1 - Examples/Demo/Demo/HeadingsView.swift | 1 - Examples/Demo/Demo/ImagesView.swift | 1 - Examples/Demo/Demo/LazyLoadingView.swift | 98 +++++++++++++++++++ Examples/Demo/Demo/ListsView.swift | 1 - Examples/Demo/Demo/QuotesView.swift | 1 - Examples/Demo/Demo/RepositoryReadmeView.swift | 1 - Examples/Demo/Demo/TablesView.swift | 1 - Examples/Demo/Demo/TextStylesView.swift | 1 - 13 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 Examples/Demo/Demo/LazyLoadingView.swift diff --git a/Examples/Demo/Demo.xcodeproj/project.pbxproj b/Examples/Demo/Demo.xcodeproj/project.pbxproj index 4395c089..557d8250 100644 --- a/Examples/Demo/Demo.xcodeproj/project.pbxproj +++ b/Examples/Demo/Demo.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 991E39882950A80C00A3012A /* CodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 991E39872950A80C00A3012A /* CodeView.swift */; }; 991E398A2950B0DF00A3012A /* TablesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 991E39892950B0DF00A3012A /* TablesView.swift */; }; 994F5D87295821AF00B2BB51 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 994F5D86295821AF00B2BB51 /* SDWebImageSwiftUI */; }; + 99939E2D298EC9B500E3337E /* LazyLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99939E2C298EC9B500E3337E /* LazyLoadingView.swift */; }; 99B59C9B29548FCF00390E9B /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = 99B59C9A29548FCF00390E9B /* Splash */; }; 99B59C9E295490B300390E9B /* TextOutputFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B59C9D295490B300390E9B /* TextOutputFormat.swift */; }; 99B59CA02954990B00390E9B /* SplashCodeSyntaxHighlighter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B59C9F2954990B00390E9B /* SplashCodeSyntaxHighlighter.swift */; }; @@ -36,6 +37,7 @@ 991E39852950A73500A3012A /* QuotesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuotesView.swift; sourceTree = ""; }; 991E39872950A80C00A3012A /* CodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeView.swift; sourceTree = ""; }; 991E39892950B0DF00A3012A /* TablesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TablesView.swift; sourceTree = ""; }; + 99939E2C298EC9B500E3337E /* LazyLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyLoadingView.swift; sourceTree = ""; }; 99B59C9D295490B300390E9B /* TextOutputFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextOutputFormat.swift; sourceTree = ""; }; 99B59C9F2954990B00390E9B /* SplashCodeSyntaxHighlighter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashCodeSyntaxHighlighter.swift; sourceTree = ""; }; 99B59CA129549AB600390E9B /* CodeSyntaxHighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeSyntaxHighlightView.swift; sourceTree = ""; }; @@ -107,6 +109,7 @@ 99DE2A3C294F256A0025E332 /* HeadingsView.swift */, 9902F2B129571D5D003A2DCC /* ImagesView.swift */, 9902F2B629575183003A2DCC /* ImageProvidersView.swift */, + 99939E2C298EC9B500E3337E /* LazyLoadingView.swift */, 99DE2A3E294F33030025E332 /* ListsView.swift */, 991E39852950A73500A3012A /* QuotesView.swift */, 99DE2A38294E1B700025E332 /* RepositoryReadmeView.swift */, @@ -228,6 +231,7 @@ 99DE2A39294E1B700025E332 /* RepositoryReadmeView.swift in Sources */, 9902F2B229571D5D003A2DCC /* ImagesView.swift in Sources */, 9902F2B729575183003A2DCC /* ImageProvidersView.swift in Sources */, + 99939E2D298EC9B500E3337E /* LazyLoadingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/Demo/Demo/CodeSyntaxHighlightView.swift b/Examples/Demo/Demo/CodeSyntaxHighlightView.swift index 5b6b98fa..671bd49f 100644 --- a/Examples/Demo/Demo/CodeSyntaxHighlightView.swift +++ b/Examples/Demo/Demo/CodeSyntaxHighlightView.swift @@ -57,7 +57,6 @@ struct CodeSyntaxHighlightView: View { Markdown(self.content) .markdownCodeSyntaxHighlighter(.splash(theme: self.theme)) } - .navigationTitle("Syntax Highlighting") } private var theme: Splash.Theme { diff --git a/Examples/Demo/Demo/CodeView.swift b/Examples/Demo/Demo/CodeView.swift index d67bb344..b3b5d194 100644 --- a/Examples/Demo/Demo/CodeView.swift +++ b/Examples/Demo/Demo/CodeView.swift @@ -46,7 +46,6 @@ struct CodeView: View { DemoView { Markdown(self.content) } - .navigationTitle("Code") } } diff --git a/Examples/Demo/Demo/ContentView.swift b/Examples/Demo/Demo/ContentView.swift index 836b5690..8f7b1a1b 100644 --- a/Examples/Demo/Demo/ContentView.swift +++ b/Examples/Demo/Demo/ContentView.swift @@ -7,36 +7,50 @@ struct ContentView: View { Section("Formatting") { NavigationLink { HeadingsView() + .navigationTitle("Headings") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Headings", systemImage: "textformat.size") } NavigationLink { ListsView() + .navigationTitle("Lists") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Lists", systemImage: "list.bullet") } NavigationLink { TextStylesView() + .navigationTitle("Text Styles") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Text Styles", systemImage: "textformat.abc") } NavigationLink { QuotesView() + .navigationTitle("Quotes") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Quotes", systemImage: "text.quote") } NavigationLink { CodeView() + .navigationTitle("Code") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Code", systemImage: "curlybraces") } NavigationLink { ImagesView() + .navigationTitle("Images") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Images", systemImage: "photo") } NavigationLink { TablesView() + .navigationTitle("Tables") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Tables", systemImage: "tablecells") } @@ -44,11 +58,15 @@ struct ContentView: View { Section("Extensibility") { NavigationLink { CodeSyntaxHighlightView() + .navigationTitle("Syntax Highlighting") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Syntax Highlighting", systemImage: "circle.grid.cross.left.filled") } NavigationLink { ImageProvidersView() + .navigationTitle("Image Providers") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Image Providers", systemImage: "powerplug") } @@ -56,14 +74,25 @@ struct ContentView: View { Section("Other") { NavigationLink { DingusView() + .navigationTitle("Dingus") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Dingus", systemImage: "character.cursor.ibeam") } NavigationLink { RepositoryReadmeView() + .navigationTitle("Repository README") + .navigationBarTitleDisplayMode(.inline) } label: { Label("Repository README", systemImage: "doc.text") } + NavigationLink { + LazyLoadingView() + .navigationTitle("Lazy Loading") + .navigationBarTitleDisplayMode(.inline) + } label: { + Label("Lazy Loading", systemImage: "scroll") + } } } .navigationTitle("MarkdownUI") diff --git a/Examples/Demo/Demo/DingusView.swift b/Examples/Demo/Demo/DingusView.swift index 66df24fd..4f7ab3d4 100644 --- a/Examples/Demo/Demo/DingusView.swift +++ b/Examples/Demo/Demo/DingusView.swift @@ -26,7 +26,6 @@ struct DingusView: View { Markdown(self.markdown) } } - .navigationTitle("Dingus") } } diff --git a/Examples/Demo/Demo/HeadingsView.swift b/Examples/Demo/Demo/HeadingsView.swift index 4bd04b38..fd97066d 100644 --- a/Examples/Demo/Demo/HeadingsView.swift +++ b/Examples/Demo/Demo/HeadingsView.swift @@ -35,7 +35,6 @@ struct HeadingsView: View { } } } - .navigationTitle("Headings") } } diff --git a/Examples/Demo/Demo/ImagesView.swift b/Examples/Demo/Demo/ImagesView.swift index f9f09bc0..e447ec3a 100644 --- a/Examples/Demo/Demo/ImagesView.swift +++ b/Examples/Demo/Demo/ImagesView.swift @@ -44,7 +44,6 @@ struct ImagesView: View { .markdownMargin(top: .em(1.6), bottom: .em(1.6)) } } - .navigationTitle("Images") } } diff --git a/Examples/Demo/Demo/LazyLoadingView.swift b/Examples/Demo/Demo/LazyLoadingView.swift new file mode 100644 index 00000000..c234d59f --- /dev/null +++ b/Examples/Demo/Demo/LazyLoadingView.swift @@ -0,0 +1,98 @@ +import MarkdownUI +import SwiftUI + +struct LazyLoadingView: View { + struct Item: Identifiable { + let id = UUID() + let content = MarkdownContent { + Heading(.level2) { + "Try MarkdownUI" + } + Paragraph { + Strong("MarkdownUI") + " is a native Markdown renderer for SwiftUI" + " compatible with the " + InlineLink( + "GitHub Flavored Markdown Spec", + destination: URL(string: "https://github.github.com/gfm/")! + ) + "." + } + Paragraph { + InlineImage(source: .randomImage()) + } + } + } + + private let about = """ + This screen demonstrates how you can use the `Markdown` view inside a `LazyVStack` and + avoid re-layouts when scrolling up content. By using a custom `ImageProvider` that + shows a placeholder while the image is loading and fixing the height of the images, + you can avoid jumps and other weird effects caused by re-layouts when scrolling up. + + > Note that this applies only when you plan to show Markdown content with images inside + > a `LazyVStack` or a `List`. + """ + + let items = Array(repeating: (), count: 100).map(Item.init) + + var body: some View { + ScrollView { + LazyVStack { + DisclosureGroup("About this demo") { + Markdown { + self.about + } + .frame(maxWidth: .infinity, alignment: .leading) + } + ForEach(self.items) { item in + Markdown(item.content) + .padding() + } + } + .padding() + } + .markdownTheme(.gitHub) + // Comment this line to see the effect of having re-layouts while scrolling up. + .markdownImageProvider(.lazyImage(aspectRatio: 4 / 3)) + } +} + +struct LazyLoadingView_Previews: PreviewProvider { + static var previews: some View { + LazyLoadingView() + } +} + +struct LazyImageProvider: ImageProvider { + let aspectRatio: CGFloat + + func makeImage(url: URL?) -> some View { + AsyncImage(url: url) { phase in + switch phase { + case .empty, .failure: + Color(.secondarySystemBackground) + case .success(let image): + image.resizable().scaledToFill() + @unknown default: + Color.clear + } + } + .aspectRatio(self.aspectRatio, contentMode: .fill) + } +} + +extension ImageProvider where Self == LazyImageProvider { + static func lazyImage(aspectRatio: CGFloat) -> Self { + LazyImageProvider(aspectRatio: aspectRatio) + } +} + +extension URL { + static func randomImage() -> URL { + let id: String = [ + "11", "23", "26", "31", "34", "58", "63", "91", "103", "119", + ].randomElement()! + return URL(string: "https://picsum.photos/id/\(id)/400/300")! + } +} diff --git a/Examples/Demo/Demo/ListsView.swift b/Examples/Demo/Demo/ListsView.swift index 1e1da59c..b048c7e6 100644 --- a/Examples/Demo/Demo/ListsView.swift +++ b/Examples/Demo/Demo/ListsView.swift @@ -88,7 +88,6 @@ struct ListsView: View { .relativeFrame(minWidth: .em(1.5), alignment: .trailing) } } - .navigationTitle("Lists") } } diff --git a/Examples/Demo/Demo/QuotesView.swift b/Examples/Demo/Demo/QuotesView.swift index 64377644..c0f2082e 100644 --- a/Examples/Demo/Demo/QuotesView.swift +++ b/Examples/Demo/Demo/QuotesView.swift @@ -34,7 +34,6 @@ struct QuotesView: View { .background(Color.teal.opacity(0.5)) } } - .navigationTitle("Quotes") } } diff --git a/Examples/Demo/Demo/RepositoryReadmeView.swift b/Examples/Demo/Demo/RepositoryReadmeView.swift index 263858c3..12f8116b 100644 --- a/Examples/Demo/Demo/RepositoryReadmeView.swift +++ b/Examples/Demo/Demo/RepositoryReadmeView.swift @@ -32,7 +32,6 @@ struct RepositoryReadmeView: View { .autocapitalization(.none) .disableAutocorrection(true) } - .navigationTitle("Repository README") } } diff --git a/Examples/Demo/Demo/TablesView.swift b/Examples/Demo/Demo/TablesView.swift index 35fc03d2..029a1220 100644 --- a/Examples/Demo/Demo/TablesView.swift +++ b/Examples/Demo/Demo/TablesView.swift @@ -44,7 +44,6 @@ struct TablesView: View { DemoView { Markdown(self.content) } - .navigationTitle("Tables") } } diff --git a/Examples/Demo/Demo/TextStylesView.swift b/Examples/Demo/Demo/TextStylesView.swift index 2c18f741..f8ef0252 100644 --- a/Examples/Demo/Demo/TextStylesView.swift +++ b/Examples/Demo/Demo/TextStylesView.swift @@ -63,7 +63,6 @@ struct TextStylesView: View { UnderlineStyle(.init(pattern: .dot)) } } - .navigationTitle("Text Styles") } }