Skip to content

Commit

Permalink
Support .refreshable
Browse files Browse the repository at this point in the history
  • Loading branch information
aabewhite committed Jul 23, 2024
1 parent 4f9619e commit 89dfd66
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 164 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,10 @@ Support levels:
</details>
</td>
</tr>
<tr>
<td>✅</td>
<td><code>.refreshable</code></td>
</tr>
<tr>
<td>🟠</td>
<td>
Expand Down Expand Up @@ -1656,6 +1660,7 @@ SwiftUI has many built-in environment keys. These keys are defined in `Environme
- `lineLimit`
- `locale`
- `openURL`
- `refresh`
- `scenePhase`
- `timeZone`
- `verticalSizeClass`
Expand Down
98 changes: 8 additions & 90 deletions Sources/SkipUI/SkipUI/Commands/Actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ public struct OpenURLAction {
}
}

public struct RefreshAction {
let action: () async -> Void

public func callAsFunction() async {
await action()
}
}

#if false

// TODO: Process for use in SkipUI
Expand Down Expand Up @@ -185,96 +193,6 @@ public struct OpenWindowAction {
public func callAsFunction<D>(id: String, value: D) where D : Decodable, D : Encodable, D : Hashable { fatalError() }
}

/// An action that initiates a refresh operation.
///
/// When the ``EnvironmentValues/refresh`` environment value contains an
/// instance of this structure, certain built-in views in the corresponding
/// ``Environment`` begin offering a refresh capability. They apply the
/// instance's handler to any refresh operation that the user initiates.
/// By default, the environment value is `nil`, but you can use the
/// ``View/refreshable(action:)`` modifier to create and store a new
/// refresh action that uses the handler that you specify:
///
/// List(mailbox.conversations) { conversation in
/// ConversationCell(conversation)
/// }
/// .refreshable {
/// await mailbox.fetch()
/// }
///
/// On iOS and iPadOS, the ``List`` in the example above offers a
/// pull to refresh gesture because it detects the refresh action. When
/// the user drags the list down and releases, the list calls the action's
/// handler. Because SkipUI declares the handler as asynchronous, it can
/// safely make long-running asynchronous calls, like fetching network data.
///
/// ### Refreshing custom views
///
/// You can also offer refresh capability in your custom views.
/// Read the ``EnvironmentValues/refresh`` environment value to get the
/// `RefreshAction` instance for a given ``Environment``. If you find
/// a non-`nil` value, change your view's appearance or behavior to offer
/// the refresh to the user, and call the instance to conduct the
/// refresh. You can call the refresh instance directly because it defines
/// a ``RefreshAction/callAsFunction()`` method that Swift calls
/// when you call the instance:
///
/// struct RefreshableView: View {
/// @Environment(\.refresh) private var refresh
///
/// var body: some View {
/// Button("Refresh") {
/// Task {
/// await refresh?()
/// }
/// }
/// .disabled(refresh == nil)
/// }
/// }
///
/// Be sure to call the handler asynchronously by preceding it
/// with `await`. Because the call is asynchronous, you can use
/// its lifetime to indicate progress to the user. For example,
/// you might reveal an indeterminate ``ProgressView`` before
/// calling the handler, and hide it when the handler completes.
///
/// If your code isn't already in an asynchronous context, create a
/// for the
/// method to run in. If you do this, consider adding a way for the
/// user to cancel the task. For more information, see
/// [Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
/// in *The Swift Programming Language*.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public struct RefreshAction : Sendable {

/// Initiates a refresh action.
///
/// Don't call this method directly. SkipUI calls it when you
/// call the ``RefreshAction`` structure that you get from the
/// ``Environment``:
///
/// struct RefreshableView: View {
/// @Environment(\.refresh) private var refresh
///
/// var body: some View {
/// Button("Refresh") {
/// Task {
/// await refresh?() // Implicitly calls refresh.callAsFunction()
/// }
/// }
/// .disabled(refresh == nil)
/// }
/// }
///
/// For information about how Swift uses the `callAsFunction()` method to
/// simplify call site syntax, see
/// [Methods with Special Names](https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID622)
/// in *The Swift Programming Language*.
/// For information about asynchronous operations in Swift, see
/// [Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html).
public func callAsFunction() async { fatalError() }
}

/// An action that activates a standard rename interaction.
///
/// Use the ``View/renameAction(_:)-6lghl`` modifier to configure the rename
Expand Down
29 changes: 28 additions & 1 deletion Sources/SkipUI/SkipUI/Containers/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.SwipeToDismiss
Expand Down Expand Up @@ -91,6 +96,7 @@ public final class List : View {
}

#if SKIP
// SKIP INSERT: @OptIn(ExperimentalMaterialApi::class)
@Composable public override func ComposeContent(context: ComposeContext) {
let style = EnvironmentValues.shared._listStyle ?? ListStyle.automatic
let backgroundVisibility = EnvironmentValues.shared._scrollContentBackground ?? Visibility.visible
Expand All @@ -103,11 +109,32 @@ public final class List : View {
ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? [])
IgnoresSafeAreaLayout(edges: ignoresSafeAreaEdges, context: context) { context in
ComposeContainer(scrollAxes: .vertical, modifier: context.modifier, fillWidth: true, fillHeight: true, then: Modifier.background(BackgroundColor(styling: styling, isItem: false))) { modifier in
Box(modifier: modifier) {
let containerModifier: Modifier
let refreshing = remember { mutableStateOf(false) }
let refreshAction = EnvironmentValues.shared.refresh
let refreshState: PullRefreshState?
if let refreshAction {
let refreshScope = rememberCoroutineScope()
refreshState = rememberPullRefreshState(refreshing.value, {
refreshScope.launch {
refreshing.value = true
refreshAction()
refreshing.value = false
}
})
containerModifier = modifier.pullRefresh(refreshState!)
} else {
refreshState = nil
containerModifier = modifier
}
Box(modifier: containerModifier) {
let density = LocalDensity.current
let headerSafeAreaHeight = headerSafeAreaHeight(safeArea, density: density)
let footerSafeAreaHeight = footerSafeAreaHeight(safeArea, density: density)
ComposeList(context: itemContext, styling: styling, headerSafeAreaHeight: headerSafeAreaHeight, footerSafeAreaHeight: footerSafeAreaHeight)
if let refreshState {
PullRefreshIndicator(refreshing.value, refreshState, Modifier.align(androidx.compose.ui.Alignment.TopCenter))
}
}
}
}
Expand Down
30 changes: 29 additions & 1 deletion Sources/SkipUI/SkipUI/Containers/ScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.PullRefreshState
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
Expand All @@ -28,6 +35,7 @@ public struct ScrollView : View {
}

#if SKIP
// SKIP INSERT: @OptIn(ExperimentalMaterialApi::class)
@Composable public override func ComposeContent(context: ComposeContext) {
// Some components in Compose have their own scrolling built in, so we'll look for them
// below before adding our scrolling modifiers
Expand All @@ -52,8 +60,28 @@ public struct ScrollView : View {
}
let contentContext = context.content()
ComposeContainer(scrollAxes: axes, modifier: context.modifier, fillWidth: axes.contains(.horizontal), fillHeight: axes.contains(.vertical), then: scrollModifier) { modifier in
Box(modifier: modifier) {
let containerModifier: Modifier
let refreshing = remember { mutableStateOf(false) }
let refreshAction = EnvironmentValues.shared.refresh
let refreshState: PullRefreshState?
if let refreshAction {
refreshState = rememberPullRefreshState(refreshing.value, {
coroutineScope.launch {
refreshing.value = true
refreshAction()
refreshing.value = false
}
})
containerModifier = modifier.pullRefresh(refreshState!)
} else {
refreshState = nil
containerModifier = modifier
}
Box(modifier: containerModifier) {
content.Compose(context: contentContext)
if let refreshState {
PullRefreshIndicator(refreshing.value, refreshState, Modifier.align(androidx.compose.ui.Alignment.TopCenter))
}
}
}
}
Expand Down
81 changes: 10 additions & 71 deletions Sources/SkipUI/SkipUI/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ extension EnvironmentValues {
set { setBuiltinValue(key: "font", value: newValue, defaultValue: { nil }) }
}

// SKIP INSERT: @OptIn(ExperimentalMaterial3AdaptiveApi::class)
public var horizontalSizeClass: UserInterfaceSizeClass? {
UserInterfaceSizeClass.fromWindowWidthSizeClass(currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass)
}

public var isEnabled: Bool {
get { builtinValue(key: "isEnabled", defaultValue: { true }) as! Bool }
set { setBuiltinValue(key: "isEnabled", value: newValue, defaultValue: { true }) }
Expand Down Expand Up @@ -265,6 +270,11 @@ extension EnvironmentValues {
set { setBuiltinValue(key: "redactionReasons", value: newValue, defaultValue: { RedactionReasons(rawValue: 0) }) }
}

public var refresh: RefreshAction? {
get { builtinValue(key: "refresh", defaultValue: { nil }) as! RefreshAction? }
set { setBuiltinValue(key: "refresh", value: newValue, defaultValue: { nil }) }
}

public var scenePhase: ScenePhase {
switch UIApplication.shared.applicationState {
case .active:
Expand All @@ -281,11 +291,6 @@ extension EnvironmentValues {
set { setBuiltinValue(key: "timeZone", value: newValue, defaultValue: { TimeZone.current }) }
}

// SKIP INSERT: @OptIn(ExperimentalMaterial3AdaptiveApi::class)
public var horizontalSizeClass: UserInterfaceSizeClass? {
UserInterfaceSizeClass.fromWindowWidthSizeClass(currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass)
}

// SKIP INSERT: @OptIn(ExperimentalMaterial3AdaptiveApi::class)
public var verticalSizeClass: UserInterfaceSizeClass? {
UserInterfaceSizeClass.fromWindowHeightSizeClass(currentWindowAdaptiveInfo().windowSizeClass.windowHeightSizeClass)
Expand Down Expand Up @@ -315,7 +320,6 @@ extension EnvironmentValues {
var openDocument: OpenDocumentAction
var openWindow: OpenWindowAction
var purchase: PurchaseAction
var refresh: RefreshAction?
var rename: RenameAction?
var resetFocus: ResetFocusAction
var authorizationController: AuthorizationController
Expand Down Expand Up @@ -1501,71 +1505,6 @@ extension EnvironmentValues {
public var searchSuggestionsPlacement: SearchSuggestionsPlacement { get { fatalError() } }
}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension EnvironmentValues {

/// A refresh action stored in a view's environment.
///
/// When this environment value contains an instance of the
/// ``RefreshAction`` structure, certain built-in views in the corresponding
/// ``Environment`` begin offering a refresh capability. They apply the
/// instance's handler to any refresh operation that the user initiates.
/// By default, the environment value is `nil`, but you can use the
/// ``View/refreshable(action:)`` modifier to create and store a new
/// refresh action that uses the handler that you specify:
///
/// List(mailbox.conversations) { conversation in
/// ConversationCell(conversation)
/// }
/// .refreshable {
/// await mailbox.fetch()
/// }
///
/// On iOS and iPadOS, the ``List`` in the example above offers a
/// pull to refresh gesture because it detects the refresh action. When
/// the user drags the list down and releases, the list calls the action's
/// handler. Because SkipUI declares the handler as asynchronous, it can
/// safely make long-running asynchronous calls, like fetching network data.
///
/// ### Refreshing custom views
///
/// You can also offer refresh capability in your custom views.
/// Read the `refresh` environment value to get the ``RefreshAction``
/// instance for a given ``Environment``. If you find
/// a non-`nil` value, change your view's appearance or behavior to offer
/// the refresh to the user, and call the instance to conduct the
/// refresh. You can call the refresh instance directly because it defines
/// a ``RefreshAction/callAsFunction()`` method that Swift calls
/// when you call the instance:
///
/// struct RefreshableView: View {
/// @Environment(\.refresh) private var refresh
///
/// var body: some View {
/// Button("Refresh") {
/// Task {
/// await refresh?()
/// }
/// }
/// .disabled(refresh == nil)
/// }
/// }
///
/// Be sure to call the handler asynchronously by preceding it
/// with `await`. Because the call is asynchronous, you can use
/// its lifetime to indicate progress to the user. For example,
/// you can reveal an indeterminate ``ProgressView`` before
/// calling the handler, and hide it when the handler completes.
///
/// If your code isn't already in an asynchronous context, create a
/// for the
/// method to run in. If you do this, consider adding a way for the
/// user to cancel the task. For more information, see
/// [Concurrency](https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html)
/// in *The Swift Programming Language*.
public var refresh: RefreshAction? { get { fatalError() } }
}

extension EnvironmentValues {

/// The configuration of a document in a ``DocumentGroup``.
Expand Down
5 changes: 4 additions & 1 deletion Sources/SkipUI/SkipUI/View/View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -856,9 +856,12 @@ extension View {
return self
}

@available(*, unavailable)
public func refreshable(action: @escaping () async -> Void) -> some View {
#if SKIP
return environment(\.refresh, RefreshAction(action: action))
#else
return self
#endif
}

@available(*, unavailable)
Expand Down

0 comments on commit 89dfd66

Please sign in to comment.