Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,78 @@ For iOS parity, return a child created with `platformContext.makeChildWebEngine(
By default this mirrors the parent `WebEngineConfiguration` and inspectability on the popup child. Pass an explicit configuration only when you intentionally want the child to diverge.
This default mirroring is configuration-level. Platform delegate assignments on the returned child (`WKUIDelegate`, `WKNavigationDelegate`) are not automatically copied from the parent, so assign them explicitly if your app depends on that behavior.

## Scroll Delegate

`SkipWeb` exposes a `WebView`-attached scroll delegate API that follows `UIScrollViewDelegate` naming where practical while remaining portable across iOS and Android.

Attach a delegate through `WebView(scrollDelegate:)`:

```swift
import SwiftUI
import SkipWeb

final class ScrollProbe: SkipWebScrollDelegate {
func scrollViewDidEndDragging(_ scrollView: WebScrollViewProxy, willDecelerate decelerate: Bool) {
print("ended drag at y=\(scrollView.contentOffset.y), decelerate=\(decelerate)")
}

func scrollViewDidEndDecelerating(_ scrollView: WebScrollViewProxy) {
print("scroll settled at y=\(scrollView.contentOffset.y)")
}
}

struct ScrollHostView: View {
private let scrollDelegate = ScrollProbe()
@State private var navigator = WebViewNavigator()

var body: some View {
WebView(
navigator: navigator,
url: URL(string: "https://example.com")!,
scrollDelegate: scrollDelegate
)
}
}
```

Supported callbacks:

- `scrollViewDidScroll(_:)`
- `scrollViewWillBeginDragging(_:)`
- `scrollViewDidEndDragging(_:willDecelerate:)`
- `scrollViewWillBeginDecelerating(_:)`
- `scrollViewDidEndDecelerating(_:)`

`WebScrollViewProxy` exposes portable geometry through `WebScrollPoint` and `WebScrollSize`:

- `contentOffset`
- `contentSize`
- `visibleSize`
- `isTracking`
- `isDragging`
- `isDecelerating`
- `isScrollEnabled`

### Platform callback semantics

| Callback | iOS | Android |
| --- | --- | --- |
| `scrollViewDidScroll(_:)` | Native `UIScrollViewDelegate.scrollViewDidScroll` | `WebView.setOnScrollChangeListener` |
| `scrollViewWillBeginDragging(_:)` | Native `UIScrollViewDelegate.scrollViewWillBeginDragging` | Inferred from touch-slop crossing, or synthesized from first scroll delta while touch is active if `ACTION_MOVE` is missed |
| `scrollViewDidEndDragging(_:willDecelerate:)` | Native `UIScrollViewDelegate.scrollViewDidEndDragging` | Inferred from touch end; `willDecelerate` is computed from fling velocity vs Android minimum fling velocity |
| `scrollViewWillBeginDecelerating(_:)` | Native `UIScrollViewDelegate.scrollViewWillBeginDecelerating` | Emitted when fling velocity crosses the deceleration threshold |
| `scrollViewDidEndDecelerating(_:)` | Native `UIScrollViewDelegate.scrollViewDidEndDecelerating` | Emitted after a short idle period (~120 ms) with no new scroll deltas during momentum |

Android deceleration is heuristic-based because `android.webkit.WebView` does not expose a direct `didEndDecelerating` callback.

### Important differences from `UIScrollViewDelegate`

- `SkipWebScrollDelegate` intentionally exposes a focused subset: no `scrollViewDidScrollToTop(_:)`, zoom callbacks, or scrolling-animation callbacks in the public API.
- On iOS, callback timing comes directly from `UIScrollViewDelegate`; on Android, drag/deceleration lifecycle callbacks are synthesized from touch and scroll signals.
- On Android, `ACTION_CANCEL` is finalized with a short grace period so nested gesture interception does not prematurely end a drag.
- On Android, a new touch during momentum immediately ends the synthetic deceleration phase before starting a new drag sequence.
- `scrollViewDidScroll(_:)` is offset-change-driven (including programmatic scroll changes), while drag/deceleration lifecycle callbacks are user-gesture-driven.

## Snapshots

`SkipWeb` provides `WebEngine.takeSnapshot(configuration:)` and `WebViewNavigator.takeSnapshot(configuration:)`
Expand Down
95 changes: 95 additions & 0 deletions SkipWebScrollDelegate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# SkipWebScrollDelegate

`SkipWebScrollDelegate` is the scroll callback API for `SkipWeb`.
Attach it directly to `WebView` when host UI needs scroll lifecycle signals such as toolbar visibility updates or end-of-scroll snapshot refreshes.

## Protocol

```swift
@MainActor
public protocol SkipWebScrollDelegate: AnyObject {
func scrollViewDidScroll(_ scrollView: WebScrollViewProxy)
func scrollViewWillBeginDragging(_ scrollView: WebScrollViewProxy)
func scrollViewDidEndDragging(_ scrollView: WebScrollViewProxy, willDecelerate decelerate: Bool)
func scrollViewWillBeginDecelerating(_ scrollView: WebScrollViewProxy)
func scrollViewDidEndDecelerating(_ scrollView: WebScrollViewProxy)
}
```

All methods have default no-op implementations.

## Usage Example

```swift
import SwiftUI
import SkipWeb

final class ScrollProbe: SkipWebScrollDelegate {
func scrollViewDidScroll(_ scrollView: WebScrollViewProxy) {
print("offset=\(scrollView.contentOffset.y)")
}

func scrollViewDidEndDragging(_ scrollView: WebScrollViewProxy, willDecelerate decelerate: Bool) {
print("drag ended, decelerate=\(decelerate)")
}

func scrollViewDidEndDecelerating(_ scrollView: WebScrollViewProxy) {
print("scroll settled")
}
}

struct ScrollHostView: View {
private let scrollDelegate = ScrollProbe()
@State private var navigator = WebViewNavigator()

var body: some View {
WebView(
navigator: navigator,
url: URL(string: "https://example.com")!,
scrollDelegate: scrollDelegate
)
}
}
```

## WebScrollViewProxy

`WebScrollViewProxy` is a portable scroll-view snapshot passed to every delegate callback.
It is a reference type, so shared delegates can distinguish different web views by identity.

```swift
public final class WebScrollViewProxy: Equatable {
public internal(set) var contentOffset: WebScrollPoint
public internal(set) var contentSize: WebScrollSize
public internal(set) var visibleSize: WebScrollSize
public internal(set) var isTracking: Bool
public internal(set) var isDragging: Bool
public internal(set) var isDecelerating: Bool
public internal(set) var isScrollEnabled: Bool
}
```

Portable geometry is expressed through:

- `WebScrollPoint(x:y:)`
- `WebScrollSize(width:height:)`

## Platform Mapping

| Callback | iOS | Android |
| --- | --- | --- |
| `scrollViewDidScroll(_:)` | `UIScrollViewDelegate.scrollViewDidScroll` | `WebView.setOnScrollChangeListener` |
| `scrollViewWillBeginDragging(_:)` | `UIScrollViewDelegate.scrollViewWillBeginDragging` | Inferred from touch-slop crossing, or synthesized from first scroll delta while touch is active if `ACTION_MOVE` is missed |
| `scrollViewDidEndDragging(_:willDecelerate:)` | `UIScrollViewDelegate.scrollViewDidEndDragging` | Inferred from touch end; `willDecelerate` is computed from fling velocity vs Android minimum fling velocity |
| `scrollViewWillBeginDecelerating(_:)` | `UIScrollViewDelegate.scrollViewWillBeginDecelerating` | Emitted when fling velocity crosses the threshold |
| `scrollViewDidEndDecelerating(_:)` | `UIScrollViewDelegate.scrollViewDidEndDecelerating` | Emitted after a short quiet period (~120 ms) with no further scroll changes |

## Notes

- Android deceleration is heuristic-based because `android.webkit.WebView` does not expose a direct `didEndDecelerating` callback.
- `SkipWebScrollDelegate` intentionally exposes a focused subset of `UIScrollViewDelegate`; top-scroll, zoom, and scrolling-animation callbacks are not in the public API.
- On iOS, callback timing comes directly from `UIScrollViewDelegate`; on Android, drag/deceleration lifecycle callbacks are synthesized from touch and scroll signals.
- On Android, `ACTION_CANCEL` is finalized with a short grace period so nested gesture interception does not prematurely end a drag.
- On Android, a new touch during momentum immediately ends the synthetic deceleration phase before starting a new drag sequence.
- `scrollViewDidScroll(_:)` is emitted for any actual scroll offset change, including programmatic scroll updates.
- Drag and deceleration lifecycle callbacks are user-gesture-driven.
101 changes: 101 additions & 0 deletions Sources/SkipWeb/WebScrollDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
#if !SKIP_BRIDGE
import SwiftUI

#if SKIP || os(iOS)

@MainActor
public protocol SkipWebScrollDelegate: AnyObject {
func scrollViewDidScroll(_ scrollView: WebScrollViewProxy)
func scrollViewWillBeginDragging(_ scrollView: WebScrollViewProxy)
func scrollViewDidEndDragging(_ scrollView: WebScrollViewProxy, willDecelerate decelerate: Bool)
func scrollViewWillBeginDecelerating(_ scrollView: WebScrollViewProxy)
func scrollViewDidEndDecelerating(_ scrollView: WebScrollViewProxy)
}

public extension SkipWebScrollDelegate {
func scrollViewDidScroll(_ scrollView: WebScrollViewProxy) {
}

func scrollViewWillBeginDragging(_ scrollView: WebScrollViewProxy) {
}

func scrollViewDidEndDragging(_ scrollView: WebScrollViewProxy, willDecelerate decelerate: Bool) {
}

func scrollViewWillBeginDecelerating(_ scrollView: WebScrollViewProxy) {
}

func scrollViewDidEndDecelerating(_ scrollView: WebScrollViewProxy) {
}
}

public struct WebScrollPoint: Equatable {
public let x: Double
public let y: Double

public init(x: Double = 0.0, y: Double = 0.0) {
self.x = x
self.y = y
}
}

public struct WebScrollSize: Equatable {
public let width: Double
public let height: Double

public init(width: Double = 0.0, height: Double = 0.0) {
self.width = width
self.height = height
}
}

@MainActor
public final class WebScrollViewProxy: Equatable {
public static func == (lhs: WebScrollViewProxy, rhs: WebScrollViewProxy) -> Bool {
lhs === rhs
}

public internal(set) var contentOffset: WebScrollPoint
public internal(set) var contentSize: WebScrollSize
public internal(set) var visibleSize: WebScrollSize
public internal(set) var isTracking: Bool
public internal(set) var isDragging: Bool
public internal(set) var isDecelerating: Bool
public internal(set) var isScrollEnabled: Bool

public init(contentOffset: WebScrollPoint = WebScrollPoint(),
contentSize: WebScrollSize = WebScrollSize(),
visibleSize: WebScrollSize = WebScrollSize(),
isTracking: Bool = false,
isDragging: Bool = false,
isDecelerating: Bool = false,
isScrollEnabled: Bool = true) {
self.contentOffset = contentOffset
self.contentSize = contentSize
self.visibleSize = visibleSize
self.isTracking = isTracking
self.isDragging = isDragging
self.isDecelerating = isDecelerating
self.isScrollEnabled = isScrollEnabled
}

func update(contentOffset: WebScrollPoint,
contentSize: WebScrollSize,
visibleSize: WebScrollSize,
isTracking: Bool,
isDragging: Bool,
isDecelerating: Bool,
isScrollEnabled: Bool) {
self.contentOffset = contentOffset
self.contentSize = contentSize
self.visibleSize = visibleSize
self.isTracking = isTracking
self.isDragging = isDragging
self.isDecelerating = isDecelerating
self.isScrollEnabled = isScrollEnabled
}
}

#endif
#endif
Loading