Repository: cybozu/WebUI
Pull request #54: Make it possible to refer to contentSize via WebViewProxy.
Author: login: Kyome22
Created: 2025-03-16T07:18:22Z | Merged: 2025-03-31T01:09:08Z

This PR makes it possible to obtain `contentSize` via `WebViewProxy`.
Similar to `refreshable()`, it uses APIs related to ScrollView, so it works on iOS but not on macOS.

I considered enclosing the provision of `contentSize` with `#if canImport(UIKit)` to make it available only on iOS, or using `@available(macOS, unavailable)` to not provide it on macOS. 
However, considering that the former would make it appear available on macOS in the documentation and the latter cannot be applied to stored properties, I decided to provide it on macOS as well. 

(On macOS, `contentSize` will always return `CGSize.zero`.)


Diff:
diff --git a/Sources/WebUI/EnhancedWKWebView.swift b/Sources/WebUI/EnhancedWKWebView.swift
index cc83889..262a1d3 100644
--- a/Sources/WebUI/EnhancedWKWebView.swift
+++ b/Sources/WebUI/EnhancedWKWebView.swift
@@ -1,28 +1,5 @@
 import WebKit
 
-/// This is a workaround for the absence of refresh control in macOS.
-#if canImport(UIKit)
-typealias RefreshControl = UIRefreshControl
-#else
-struct RefreshControl {
-    enum ControlEvent {
-        case valueChanged
-    }
-    func addTarget(_ target: Any?, action: Selector, for controlEvents: ControlEvent) {}
-    func endRefreshing() {}
-}
-private extension WKWebView {
-    struct ScrollView {
-        var bounces = false
-        var refreshControl: RefreshControl? = .init()
-    }
-    var scrollView: ScrollView {
-        get { .init() }
-        set {}
-    }
-}
-#endif
-
 class EnhancedWKWebView: WKWebView {
     override var navigationDelegate: (any WKNavigationDelegate)? {
         get {
diff --git a/Sources/WebUI/RefreshControl.swift b/Sources/WebUI/RefreshControl.swift
new file mode 100644
index 0000000..cbc8b6d
--- /dev/null
+++ b/Sources/WebUI/RefreshControl.swift
@@ -0,0 +1,27 @@
+import WebKit
+
+/// This is a workaround for the absence of refresh control in macOS.
+#if canImport(UIKit)
+typealias RefreshControl = UIRefreshControl
+#else
+struct RefreshControl {
+    enum ControlEvent {
+        case valueChanged
+    }
+
+    func addTarget(_ target: Any?, action: Selector, for controlEvents: ControlEvent) {}
+    func endRefreshing() {}
+}
+
+extension WKWebView {
+    struct ScrollView {
+        var bounces = false
+        var refreshControl: RefreshControl? = .init()
+    }
+
+    var scrollView: ScrollView {
+        get { .init() }
+        set {}
+    }
+}
+#endif
diff --git a/Sources/WebUI/WebViewProxy.swift b/Sources/WebUI/WebViewProxy.swift
index 00be277..c308288 100644
--- a/Sources/WebUI/WebViewProxy.swift
+++ b/Sources/WebUI/WebViewProxy.swift
@@ -29,6 +29,18 @@ public final class WebViewProxy: ObservableObject {
     /// A Boolean value indicating whether there is a forward item in the back-forward list that can be navigated to.
     @Published public private(set) var canGoForward = false
 
+    @Published private var _contentSize: CGSize = .zero
+
+    /// The size of the content view.
+    @available(macOS, unavailable)
+    public var contentSize: CGSize { _contentSize }
+
+    @Published private var _contentOffset: CGPoint = .zero
+
+    // The point at which the origin of the content view is offset from the origin of the scroll view.
+    @available(macOS, unavailable)
+    public var contentOffset: CGPoint { _contentOffset }
+
     private var tasks: [Task<Void, Never>] = []
 
     nonisolated init() {}
@@ -81,8 +93,22 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
         ]
+        #if canImport(UIKit)
+        tasks.append(contentsOf: [
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentOffset).bufferedValues() {
+                    self?._contentOffset = value
+                }
+            },
+        ])
+        #endif
     }
 
     /// Navigates to a requested URL.


Reviews:
user: login: b1ackturtle
body: Thank you for the implementation.

Since pull-to-refresh is an iOS-specific gesture, it's reasonable for the `refreshable()` functionality to work only on iOS. However, the property you added for obtaining the content size isn’t exclusive to iOS—it’s a value that should naturally be available on macOS as well. Returning `CGSize.zero` on macOS feels off to me.

It might be better either to implement it properly on macOS or to explicitly indicate that it’s not implemented on that platform (for example, using `fatalError("Not implemented.")`).
What do you think?
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-16T15:02:14Z
user: login: b1ackturtle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T15:16:17Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T04:22:34Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T04:26:04Z
user: login: b1ackturtle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T10:59:02Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T03:25:26Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T03:41:57Z
user: login: b1ackturtle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T08:30:48Z
user: login: b1ackturtle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T08:34:08Z
user: login: b1ackturtle
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T08:40:14Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T00:50:30Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T00:51:55Z
user: login: Kyome22
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T00:55:05Z
user: login: b1ackturtle
body: LGTM
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-30T14:11:13Z
user: login: elmetal
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T00:09:52Z

Review comments:
diff_hunk: @@ -81,7 +87,12 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
         ]
path: Sources/WebUI/WebViewProxy.swift
user: login: b1ackturtle
body: If the implementation of KVO is limited to iOS, as shown in the code below, some of the changes added in this PR might be redundant.
```suggestion
            },
        ]
        
        #if canImport(UIKit)
        tasks.append(
            Task { @MainActor [weak self] in
                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
                    self?._contentSize = value
                }
            }
        )
        #endif
```
created_at: 2025-03-19T15:16:17Z
author_association: COLLABORATOR
diff_hunk: @@ -81,7 +87,12 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
         ]
path: Sources/WebUI/WebViewProxy.swift
user: login: Kyome22
body: I also tried your proposed implementation once.

I am puzzled by the gap between the API implementation policy at the time of refreshable() and this implementation policy. I am concerned that if we proceed with the implementation strictly to be provided only on certain platforms, there will be a large number of canImport preprocessors in the source code in the future.

On the other hand, the proposed implementation has some point.
created_at: 2025-03-20T04:22:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,7 +87,12 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
         ]
path: Sources/WebUI/WebViewProxy.swift
user: login: Kyome22
body: Apart from this PR, it is also possible to implement refreshable() strictly to be served only on iOS.
However, when I tried this in the past, I had to write many canImport preprocessors.

I made a provisionally strict implementation in https://github.com/cybozu/WebUI/pull/56.
It is possible to make the strictness a bit looser as far as the compilation goes, but how much would be better?
created_at: 2025-03-20T04:26:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,7 +87,12 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
         ]
path: Sources/WebUI/WebViewProxy.swift
user: login: b1ackturtle
body: `WebView.refreshable()` is designed with strong consideration for `View.refreshable(action:)`. While `View.refreshable(action:)` is available on macOS, it does not function as expected. Therefore, I believe there is no need to modify the current implementation of `refreshable()` in WebUI.

On the other hand, `contentSize` is an attribute that is inherently expected to be usable on macOS as well. Since it is not currently provided, I believe this exceptional case should be explicitly expressed in the code responsible for implementing `contentSize`.
created_at: 2025-03-20T10:59:02Z
author_association: COLLABORATOR
diff_hunk: @@ -81,7 +87,12 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
         ]
path: Sources/WebUI/WebViewProxy.swift
user: login: Kyome22
body: I'll try to fix it according to your suggested approach.
created_at: 2025-03-21T03:25:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,7 +87,12 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
+            Task { @MainActor [weak self] in
+                for await value in webView.scrollView.publisher(for: \.contentSize).bufferedValues() {
+                    self?._contentSize = value
+                }
+            },
         ]
path: Sources/WebUI/WebViewProxy.swift
user: login: Kyome22
body: I did. [704e241](https://github.com/cybozu/WebUI/pull/54/commits/704e241f33f4a24c3fa5585a94d5aa649c160e58)
created_at: 2025-03-21T03:41:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,8 +93,22 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
path: Sources/WebUI/WebViewProxy.swift
user: login: b1ackturtle
body: nits: Unnecessary differences.

created_at: 2025-03-25T08:30:48Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,26 @@
+import WebKit
+
+/// This is a workaround for the absence of refresh control in macOS.
+#if canImport(UIKit)
+typealias RefreshControl = UIRefreshControl
+#else
+struct RefreshControl {
+    enum ControlEvent {
+        case valueChanged
+    }
+
+    func addTarget(_ target: Any?, action: Selector, for controlEvents: ControlEvent) {}
+    func endRefreshing() {}
+}
+extension WKWebView {
path: Sources/WebUI/RefreshControl.swift
user: login: b1ackturtle
body: nits: It might be better to have blank lines.

```suggestion
}

extension WKWebView {
```
created_at: 2025-03-25T08:34:08Z
author_association: COLLABORATOR
diff_hunk: @@ -29,6 +29,18 @@ public final class WebViewProxy: ObservableObject {
     /// A Boolean value indicating whether there is a forward item in the back-forward list that can be navigated to.
     @Published public private(set) var canGoForward = false
 
+    @Published private var _contentSize: CGSize = .zero
+
+    /// The size of the content view.
+    @available(macOS, unavailable)
+    public var contentSize: CGSize { _contentSize }
+
+    @Published private var _contentOffset: CGPoint = .zero
+
+    // The point at which the origin of the content view is offset from the origin of the scroll view.
+    @available(macOS, unavailable)
+    public var contentOffset: CGPoint { _contentOffset }
path: Sources/WebUI/WebViewProxy.swift
user: login: b1ackturtle
body: If possible, we'd like to ensure these features work as intended through unit tests or UI tests.
created_at: 2025-03-25T08:40:14Z
author_association: COLLABORATOR
diff_hunk: @@ -81,8 +93,22 @@ public final class WebViewProxy: ObservableObject {
                 for await value in webView.publisher(for: \.canGoForward).bufferedValues() {
                     self?.canGoForward = value
                 }
-            }
+            },
path: Sources/WebUI/WebViewProxy.swift
user: login: Kyome22
body: Recently I've been adding a comma to the last element in an array.
created_at: 2025-03-26T00:50:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,26 @@
+import WebKit
+
+/// This is a workaround for the absence of refresh control in macOS.
+#if canImport(UIKit)
+typealias RefreshControl = UIRefreshControl
+#else
+struct RefreshControl {
+    enum ControlEvent {
+        case valueChanged
+    }
+
+    func addTarget(_ target: Any?, action: Selector, for controlEvents: ControlEvent) {}
+    func endRefreshing() {}
+}
+extension WKWebView {
path: Sources/WebUI/RefreshControl.swift
user: login: Kyome22
body: Fixed by [c2a2d29](https://github.com/cybozu/WebUI/pull/54/commits/c2a2d29f102cbb0e03d13b6b3181bc3b00bb1744)
created_at: 2025-03-26T00:51:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,6 +29,18 @@ public final class WebViewProxy: ObservableObject {
     /// A Boolean value indicating whether there is a forward item in the back-forward list that can be navigated to.
     @Published public private(set) var canGoForward = false
 
+    @Published private var _contentSize: CGSize = .zero
+
+    /// The size of the content view.
+    @available(macOS, unavailable)
+    public var contentSize: CGSize { _contentSize }
+
+    @Published private var _contentOffset: CGPoint = .zero
+
+    // The point at which the origin of the content view is offset from the origin of the scroll view.
+    @available(macOS, unavailable)
+    public var contentOffset: CGPoint { _contentOffset }
path: Sources/WebUI/WebViewProxy.swift
user: login: Kyome22
body: I was also concerned about the lack of testing for other properties, which I would like to implement together in a separate PR.
created_at: 2025-03-26T00:55:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: Kyome22
created_at: 2025-03-17T06:28:59Z
body: A quick research shows that we may need to call `evaluateJavascript()` to get the `contentSize` on macOS.
https://medium.com/bobo-shone/how-to-calculate-wkwebview-content-height-aec856e86d56

Ideally, the API would be implemented only for iOS and the documentation would indicate that it is not provided for macOS.
It might be a good idea to explore that approach a bit more.
author_association: CONTRIBUTOR
user: login: Kyome22
created_at: 2025-03-17T07:35:03Z
body: I found a solution.
[4e8fa4c](https://github.com/cybozu/WebUI/pull/54/commits/4e8fa4c1069b15dca555336da03872551a9f193f)
author_association: CONTRIBUTOR
user: login: Kyome22
created_at: 2025-03-19T14:59:03Z
body: @b1ackturtle  In #55, the user also want `contentOffset`, so I think it would be a good idea to implement it in this PR. What do you think?
author_association: CONTRIBUTOR
user: login: b1ackturtle
created_at: 2025-03-19T15:09:52Z
body: @Kyome22 
> In #55, the user also want `contentOffset`, so I think it would be a good idea to implement it in this PR. What do you think?

When implementing `contentOffset`, will it also be available only on iOS? If so, I have no strong objections to including it in this PR.

author_association: COLLABORATOR
user: login: mergesort
created_at: 2025-03-21T00:43:16Z
body: Hi there, just chiming in since I'm the person who requested this feature. Unfortunately I'm on my phone (and on my honeymoon) so I can't pull the code and verify this, but my intention is to have this run in Mac Catalyst as well as on iOS. I just wanted to double check to make sure it wasn't necessary to add `&& targetEnvironment(macCatalyst)` whenever you import UIKit, since there was discussion about what platform this code should run on.

Thank you so much!
author_association: NONE
user: login: Kyome22
created_at: 2025-03-21T03:51:41Z
body: @b1ackturtle 
> When implementing `contentOffset`, will it also be available only on iOS? If so, I have no strong objections to including it in this PR.

I added implementation of `contentOffset` with [bc1ebe5](https://github.com/cybozu/WebUI/pull/54/commits/bc1ebe5c409f25772ebb129caedd9b1508a02955).
author_association: CONTRIBUTOR