diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index c88b6979..f88337eb 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -22,13 +22,18 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest + + - name: Download iOS Simulator + run: | + xcodebuild -downloadPlatform "iOS Simulator" - name: Build for iOS run: | xcodebuild clean build \ -scheme ${{ env.PROJECT_SCHEME }} \ -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone 15 Pro' + -destination 'platform=iOS Simulator,name=iPhone 15 Pro' \ + -quiet build-macos: runs-on: macos-latest @@ -46,7 +51,8 @@ jobs: xcodebuild clean build \ -scheme ${{ env.PROJECT_SCHEME }} \ -sdk macosx \ - -destination 'platform=OS X' + -destination 'platform=OS X' \ + -quiet build-tvos: runs-on: macos-latest @@ -58,13 +64,18 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest - + + - name: Download tvOS Simulator + run: | + xcodebuild -downloadPlatform "tvOS Simulator" + - name: Build for tvOS run: | xcodebuild clean build \ -scheme ${{ env.PROJECT_SCHEME }} \ -sdk appletvsimulator \ - -destination 'platform=tvOS Simulator,name=Apple TV' + -destination 'platform=tvOS Simulator,name=Apple TV' \ + -quiet build-watchos: runs-on: macos-latest @@ -76,13 +87,18 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest + + - name: Download watchOS Simulator + run: | + xcodebuild -downloadPlatform "watchOS Simulator" - name: Build for watchOS run: | xcodebuild clean build \ -scheme ${{ env.PROJECT_SCHEME }} \ -sdk watchsimulator \ - -destination 'platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)' + -destination 'platform=watchOS Simulator,name=Apple Watch Ultra 2 (49mm)' \ + -quiet build-visionos: runs-on: macos-latest @@ -94,10 +110,15 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest + + - name: Download visionOS Simulator + run: | + xcodebuild -downloadPlatform "visionOS Simulator" - name: Build for visionOS run: | xcodebuild clean build \ -scheme ${{ env.PROJECT_SCHEME }} \ -sdk xros \ - -destination 'platform=visionOS Simulator,name=Apple Vision Pro' + -destination 'platform=visionOS Simulator,name=Apple Vision Pro' \ + -quiet diff --git a/Package.swift b/Package.swift index 727db047..75a3ab64 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( .iOS(.v15), .tvOS(.v15), .watchOS(.v8), + .visionOS(.v1), ], products: [ .library(name: "MarkdownView", targets: ["MarkdownView"]), diff --git a/README.md b/README.md index 3d018b68..9a407f49 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ You can use MarkdownView in the following platforms: * iOS 15.0+ * watchOS 8.0+ * tvOS 15.0+ +* visionOS 1.0+ # Highlighted Features @@ -114,13 +115,6 @@ MarkdownView(text: markdownText) The implementation of the block directive is exactly the same way. -# Todos - -- [x] watchOS support. (specifically watchOS 8.0+) -- [x] Table support for iOS 15.0, macOS 12.0 and tvOS 15.0. -- [x] Add support for font size adjustments using SwiftUI built-in `.font(_:)` modifier. -- [x] Built-in image providers improvements. - # Swift Package Manager In your `Package.swift` Swift Package Manager manifest, add the following dependency to your `dependencies` argument: diff --git a/Sources/MarkdownView/Helper/SwiftUI+OnChangeModifier.swift b/Sources/MarkdownView/Helper/SwiftUI+OnChangeModifier.swift new file mode 100644 index 00000000..ed0d875b --- /dev/null +++ b/Sources/MarkdownView/Helper/SwiftUI+OnChangeModifier.swift @@ -0,0 +1,184 @@ +import SwiftUI + +extension View { + + /// Adds a modifier for this view that fires an action when a specific + /// value changes. + /// + /// You can use `onChange` to trigger a side effect as the result of a + /// value changing, such as an `Environment` key or a `Binding`. + /// + /// The system may call the action closure on the main actor, so avoid + /// long-running tasks in the closure. If you need to perform such tasks, + /// detach an asynchronous background task. + /// + /// When the value changes, the new version of the closure will be called, + /// so any captured values will have their values from the time that the + /// observed value has its new value. The old and new observed values are + /// passed into the closure. In the following code example, `PlayerView` + /// passes both the old and new values to the model. + /// + /// struct PlayerView: View { + /// var episode: Episode + /// @State private var playState: PlayState = .paused + /// + /// var body: some View { + /// VStack { + /// Text(episode.title) + /// Text(episode.showTitle) + /// PlayButton(playState: $playState) + /// } + /// .onChange(of: playState) { oldState, newState in + /// model.playStateDidChange(from: oldState, to: newState) + /// } + /// } + /// } + /// + /// - Parameters: + /// - value: The value to check against when determining whether + /// to run the closure. + /// - initial: Whether the action should be run when this view initially + /// appears. + /// - action: A closure to run when the value changes. + /// - oldValue: The old value that failed the comparison check (or the + /// initial value when requested). + /// - newValue: The new value that failed the comparison check. + /// + /// - Returns: A view that fires an action when the specified value changes. + @_disfavoredOverload + @available(iOS, introduced: 14.0, deprecated: 17.0) + @available(macOS, introduced: 11.0, deprecated: 14.0) + @available(tvOS, introduced: 14.0, deprecated: 17.0) + @available(watchOS, introduced: 7.0, deprecated: 10.0) + @available(visionOS, unavailable) + public func onChange( + of value: E, + initial: Bool = false, + _ action: @escaping (_ oldValue: E, _ newValue: E) -> Void + ) -> some View { + modifier( + OnChangeModifier( + value: value, + initial: initial, + action: .init(action) + ) + ) + } + + /// Adds a modifier for this view that fires an action when a specific + /// value changes. + /// + /// You can use `onChange` to trigger a side effect as the result of a + /// value changing, such as an `Environment` key or a `Binding`. + /// + /// The system may call the action closure on the main actor, so avoid + /// long-running tasks in the closure. If you need to perform such tasks, + /// detach an asynchronous background task. + /// + /// When the value changes, the new version of the closure will be called, + /// so any captured values will have their values from the time that the + /// observed value has its new value. In the following code example, + /// `PlayerView` calls into its model when `playState` changes model. + /// + /// struct PlayerView: View { + /// var episode: Episode + /// @State private var playState: PlayState = .paused + /// + /// var body: some View { + /// VStack { + /// Text(episode.title) + /// Text(episode.showTitle) + /// PlayButton(playState: $playState) + /// } + /// .onChange(of: playState) { + /// model.playStateDidChange(state: playState) + /// } + /// } + /// } + /// + /// - Parameters: + /// - value: The value to check against when determining whether + /// to run the closure. + /// - initial: Whether the action should be run when this view initially + /// appears. + /// - action: A closure to run when the value changes. + /// + /// - Returns: A view that fires an action when the specified value changes. + @_disfavoredOverload + @available(iOS, introduced: 14.0, deprecated: 17.0) + @available(macOS, introduced: 11.0, deprecated: 14.0) + @available(tvOS, introduced: 14.0, deprecated: 17.0) + @available(watchOS, introduced: 7.0, deprecated: 10.0) + @available(visionOS, unavailable) + public func onChange( + of value: E, + initial: Bool = false, + _ action: @escaping () -> Void + ) -> some View { + modifier( + OnChangeModifier( + value: value, + initial: initial, + action: .init(action) + ) + ) + } +} + +@available(iOS, introduced: 14.0, deprecated: 17.0) +@available(macOS, introduced: 11.0, deprecated: 14.0) +@available(tvOS, introduced: 14.0, deprecated: 17.0) +@available(watchOS, introduced: 7.0, deprecated: 10.0) +@available(visionOS, unavailable) +struct OnChangeModifier: ViewModifier { + var value: E + var initial: Bool + + struct ActionPack { + var actionWithValues: ((E, E) -> Void)? + var simpleAction: (() -> Void)? + + func callAsFunction(before: E, after: E) { + if let actionWithValues { + actionWithValues(before, after) + } else if let simpleAction { + simpleAction() + } + } + + init(_ action: @escaping (E, E) -> Void) { + self.actionWithValues = action + self.simpleAction = nil + } + + init(_ action: @escaping () -> Void) { + self.simpleAction = action + self.actionWithValues = nil + } + } + var action: ActionPack + + func body(content: Content) -> some View { + if #available(macOS 14, iOS 17, tvOS 17, watchOS 10, *) { + content + .onChange( + of: value, + initial: initial, + action.callAsFunction(before:after:) + ) + } else { + content + .onAppear(perform: initialAction) + .onChange(of: value) { [value] newValue in + Task { @MainActor in + action(before: value, after: newValue) + } + } + } + } + + private func initialAction() { + guard initial else { return } + action(before: value, after: value) + } +} diff --git a/Sources/MarkdownView/MarkdownView.swift b/Sources/MarkdownView/MarkdownView.swift index 356bcf21..76a7e0b6 100644 --- a/Sources/MarkdownView/MarkdownView.swift +++ b/Sources/MarkdownView/MarkdownView.swift @@ -76,12 +76,12 @@ public struct MarkdownView: View { // Received a debouncedText, we need to reload MarkdownView. .onReceive(contentUpdater.textUpdater, perform: updateView(text:)) // Push current text, waiting for next update. - .onChange(of: text, perform: contentUpdater.push(_:)) + .onChange(of: text) { contentUpdater.push(text) } } .if(renderingMode == .immediate && renderingThread == .background) { content in content // Immediately update MarkdownView when text changes. - .onChange(of: text, perform: updateView(text:)) + .onChange(of: text) { updateView(text: text) } } // Load view immediately after the first launch. // Receive configuration changes and reload MarkdownView to fit. diff --git a/Sources/MarkdownView/Renderer/Renderer+CodeBlock.swift b/Sources/MarkdownView/Renderer/Renderer+CodeBlock.swift index 9e437174..18706c5a 100644 --- a/Sources/MarkdownView/Renderer/Renderer+CodeBlock.swift +++ b/Sources/MarkdownView/Renderer/Renderer+CodeBlock.swift @@ -20,14 +20,14 @@ extension Renderer { extension Renderer { mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { Result { - #if os(watchOS) || os(tvOS) - SwiftUI.Text(codeBlock.code) - #else + #if canImport(Highlightr) HighlightedCodeBlock( language: codeBlock.language, code: codeBlock.code, theme: configuration.codeBlockTheme ) + #else + SwiftUI.Text(codeBlock.code) #endif } } diff --git a/Sources/MarkdownView/View/SVGView.swift b/Sources/MarkdownView/View/SVGView.swift index 0ddefef4..87f620e2 100644 --- a/Sources/MarkdownView/View/SVGView.swift +++ b/Sources/MarkdownView/View/SVGView.swift @@ -86,7 +86,7 @@ fileprivate struct _SVGViewBridge: NSViewRepresentable { Coordinator(updateSize: updateSize) } } -#elseif os(iOS) +#elseif os(iOS) || os(visionOS) fileprivate struct _SVGViewBridge: UIViewRepresentable { var html: String var updateSize: (CGSize) -> Void