Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1984071
Update ContentUnavailableView.swift
Dhruv-Clockworks Sep 28, 2026
22cd34e
Support safeAreaInset and safeAreaPadding
Dhruv-Clockworks Sep 28, 2026
6622d64
Support collapsible sections, OutlineGroup, and unbound DisclosureGroup
Dhruv-Clockworks Sep 28, 2026
64a9700
Support search suggestions, completions, scopes, and placement
Dhruv-Clockworks Sep 28, 2026
d2d5773
Add EditButton, editMode, selection context menu, and selectionDisabled
Dhruv-Clockworks Sep 28, 2026
d919d6a
Add scroll transitions, visibility, geometry, anchor, indicator flash…
Dhruv-Clockworks Sep 28, 2026
a978c5d
Support list row separator alignment guides
Dhruv-Clockworks Sep 28, 2026
466a1db
Support draggable and dropDestination for views and ForEach rows
Dhruv-Clockworks Sep 28, 2026
64b3782
Support textSelection and style-aware section header typography
Dhruv-Clockworks Sep 28, 2026
f32ec80
Add all list styles and list modifiers, and integrate new features in…
Dhruv-Clockworks Sep 28, 2026
2463a30
Bridge collapsible sections, OutlineGroup, and unbound DisclosureGroup
Dhruv-Clockworks Sep 28, 2026
80a8614
Bridge List selection and row/section modifiers
Dhruv-Clockworks Sep 28, 2026
ff8d41c
Bridge ContentUnavailableView
Dhruv-Clockworks Sep 28, 2026
09cd4af
Bridge search suggestions, completions, scopes, and placement
Dhruv-Clockworks Sep 28, 2026
9b0a557
Bridge EditButton, editMode, list environment values, and selection m…
Dhruv-Clockworks Sep 28, 2026
cd86c9b
Bridge safeAreaInset and safeAreaPadding
Dhruv-Clockworks Sep 28, 2026
7701efb
Bridge textSelection
Dhruv-Clockworks Sep 28, 2026
ab2ebfb
Bridge list row separator alignment guides
Dhruv-Clockworks Sep 28, 2026
90b2392
Bridge scroll transitions, geometry, visibility, anchor, bounce, flas…
Dhruv-Clockworks Sep 28, 2026
b6afc32
Bridge draggable and dropDestination
Dhruv-Clockworks Sep 28, 2026
833faeb
Retrigger CI checks
gurmeherchawla Sep 29, 2026
19f6d7b
Merge remote-tracking branch 'upstream/main' into feature/list-modifiers
Dhruv-Clockworks Oct 6, 2026
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
6 changes: 6 additions & 0 deletions Sources/SkipUI/SkipUI/Commands/ContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.abs

/// The menu and primary action set by `contextMenu(forSelectionType:menu:primaryAction:)`.
struct SelectionContextMenu {
let menu: (Set<AnyHashable>) -> any View
let primaryAction: ((Set<AnyHashable>) -> Void)?
}

/// Modifier that wraps content in a long-press-triggered dropdown menu.
class ContextMenuModifier: RenderModifier {
let menuItems: ComposeBuilder
Expand Down
5 changes: 0 additions & 5 deletions Sources/SkipUI/SkipUI/Commands/DragDrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,6 @@ public struct DropSession : Identifiable {
}

extension View {
@available(*, unavailable)
public func dropDestination(for type: Any.Type? = nil, isEnabled: Bool = true, action: @escaping (_ items: [Any], _ session: DropSession) -> Void) -> some View /* where T : Transferable */ {
return self
}

@available(*, unavailable)
public func onDragSessionUpdated(_ onUpdate: @escaping (DragSession) -> Void) -> some View {
return self
Expand Down
43 changes: 43 additions & 0 deletions Sources/SkipUI/SkipUI/Commands/EditMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,47 @@ public enum EditMode : Hashable {
}
}

/// Bridges an `editMode` binding by the mode's case index.
// SKIP @bridge
public final class EditModeBinding {
// SKIP @bridge
public let getMode: () -> Int
// SKIP @bridge
public let setMode: (Int) -> Void

// SKIP @bridge
public init(getMode: @escaping () -> Int, setMode: @escaping (Int) -> Void) {
self.getMode = getMode
self.setMode = setMode
}

#if SKIP
convenience init(_ binding: Binding<EditMode>) {
self.init(getMode: { binding.wrappedValue.bridgedIndex }, setMode: { binding.wrappedValue = EditMode(bridgedIndex: $0) })
}

var binding: Binding<EditMode> {
return Binding(get: { EditMode(bridgedIndex: self.getMode()) }, set: { self.setMode($0.bridgedIndex) })
}
#endif
}

extension EditMode {
init(bridgedIndex: Int) {
switch bridgedIndex {
case 1: self = .transient
case 2: self = .active
default: self = .inactive
}
}

var bridgedIndex: Int {
switch self {
case .inactive: return 0
case .transient: return 1
case .active: return 2
}
}
}

#endif
182 changes: 170 additions & 12 deletions Sources/SkipUI/SkipUI/Commands/Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Foundation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
Expand Down Expand Up @@ -44,15 +45,15 @@ import kotlinx.coroutines.launch
extension View {
public func searchable(text: Binding<String>, placement: SearchFieldPlacement = .automatic, prompt: Text? = nil) -> any View {
#if SKIP
return ModifiedContent(content: self, modifier: SearchableModifier(text: text, prompt: prompt))
return ModifiedContent(content: self, modifier: SearchableModifier(text: text, prompt: prompt, placement: placement))
#else
return self
#endif
}

public func searchable(text: Binding<String>, isPresented: Binding<Bool>, placement: SearchFieldPlacement = .automatic, prompt: Text? = nil) -> any View {
#if SKIP
return ModifiedContent(content: self, modifier: SearchableModifier(text: text, prompt: prompt, isPresented: isPresented))
return ModifiedContent(content: self, modifier: SearchableModifier(text: text, prompt: prompt, placement: placement, isPresented: isPresented))
#else
return self
#endif
Expand All @@ -63,6 +64,11 @@ extension View {
return searchable(text: Binding(get: getText, set: setText), prompt: prompt)
}

// SKIP @bridge
public func searchable(getText: @escaping () -> String, setText: @escaping (String) -> Void, prompt: Text?, bridgedPlacement: Int) -> any View {
return searchable(text: Binding(get: getText, set: setText), placement: SearchFieldPlacement(rawValue: bridgedPlacement), prompt: prompt)
}

// SKIP @bridge
public func searchable(getText: @escaping () -> String, setText: @escaping (String) -> Void, getIsPresented: @escaping () -> Bool, setIsPresented: @escaping (Bool) -> Void, prompt: Text?) -> any View {
return searchable(text: Binding(get: getText, set: setText), isPresented: Binding(get: getIsPresented, set: setIsPresented), prompt: prompt)
Expand All @@ -80,6 +86,84 @@ extension View {
return searchable(text: text, placement: placement, prompt: Text(verbatim: prompt))
}

public func searchable(text: Binding<String>, placement: SearchFieldPlacement = .automatic, prompt: Text? = nil, @ViewBuilder suggestions: () -> any View) -> any View {
return searchable(text: text, placement: placement, prompt: prompt).searchSuggestions(suggestions)
}

public func searchable(text: Binding<String>, placement: SearchFieldPlacement = .automatic, prompt: LocalizedStringKey, @ViewBuilder suggestions: () -> any View) -> any View {
return searchable(text: text, placement: placement, prompt: Text(prompt), suggestions: suggestions)
}

public func searchable(text: Binding<String>, placement: SearchFieldPlacement = .automatic, prompt: String, @ViewBuilder suggestions: () -> any View) -> any View {
return searchable(text: text, placement: placement, prompt: Text(verbatim: prompt), suggestions: suggestions)
}

/// Suggestions replace a searchable `List`'s rows while its search field is focused.
public func searchSuggestions(@ViewBuilder _ suggestions: () -> any View) -> any View {
#if SKIP
return environment(\._searchSuggestions, ComposeBuilder.from(suggestions), affectsEvaluate: false)
#else
return self
#endif
}

// SKIP @bridge
public func searchSuggestions(bridgedSuggestions: any View) -> any View {
return searchSuggestions { bridgedSuggestions }
}

// SKIP @bridge
public func searchSuggestions(bridgedVisibility: Int, bridgedPlacements: Int) -> any View {
return searchSuggestions(Visibility(rawValue: bridgedVisibility) ?? .automatic, for: SearchSuggestionsPlacement.Set(rawValue: bridgedPlacements))
}

public func searchSuggestions(_ visibility: Visibility, for placements: SearchSuggestionsPlacement.Set) -> any View {
#if SKIP
guard placements.contains(.content) else {
return self
}
return environment(\._searchSuggestionsVisibility, visibility, affectsEvaluate: false)
#else
return self
#endif
}

/// Tapping this suggestion replaces the search text with `completion` and submits the search.
// SKIP @bridge
public func searchCompletion(_ completion: String) -> any View {
#if SKIP
return ModifiedContent(content: self, modifier: SearchCompletionModifier(completion: completion))
#else
return self
#endif
}

public func searchScopes<V>(_ scope: Binding<V>, @ViewBuilder scopes: () -> any View) -> any View where V : Hashable {
return searchScopes(scope, activation: .automatic, scopes)
}

/// Bridged scopes, tagged like `Picker` options.
// SKIP @bridge
public func searchScopes(getScope: @escaping () -> Any?, setScope: @escaping (Any?) -> Void, bridgedActivation: Int, bridgedScopes: any View) -> any View {
#if SKIP
let selection = Binding<Any?>(get: getScope, set: setScope) as! Binding<Any>
let searchScopes = SearchScopes(selection: selection, content: ComposeBuilder.from { bridgedScopes }, activation: SearchScopeActivation(rawValue: bridgedActivation))
return environment(\._searchScopes, searchScopes, affectsEvaluate: false)
#else
return self
#endif
}

/// Scopes render as a segmented picker below the search field.
public func searchScopes<V>(_ scope: Binding<V>, activation: SearchScopeActivation, @ViewBuilder _ scopes: () -> any View) -> any View where V : Hashable {
#if SKIP
let searchScopes = SearchScopes(selection: scope as! Binding<Any>, content: ComposeBuilder.from(scopes), activation: activation)
return environment(\._searchScopes, searchScopes, affectsEvaluate: false)
#else
return self
#endif
}

@available(*, unavailable)
public func searchToolbarBehavior(_ behavior: SearchToolbarBehavior) -> some View {
return self
Expand All @@ -95,18 +179,20 @@ public struct SearchFieldPlacement : RawRepresentable {

public static let automatic = SearchFieldPlacement(rawValue: 0)

@available(*, unavailable)
public static let toolbar = SearchFieldPlacement(rawValue: 1)

@available(*, unavailable)
public static let sidebar = SearchFieldPlacement(rawValue: 2)

@available(*, unavailable)
public static let navigationBarDrawer = SearchFieldPlacement(rawValue: 3)

@available(*, unavailable)
/// With `.always`, the search field stays visible instead of scrolling away with the content.
public static func navigationBarDrawer(displayMode: SearchFieldPlacement.NavigationBarDrawerDisplayMode) -> SearchFieldPlacement {
return SearchFieldPlacement(rawValue: 4)
return SearchFieldPlacement(rawValue: displayMode == .always ? 4 : 3)
}

/// Whether the search field remains visible while content scrolls.
var isAlwaysVisible: Bool {
return rawValue == 4
}

public enum NavigationBarDrawerDisplayMode {
Expand All @@ -120,6 +206,36 @@ public enum SearchToolbarBehavior: Hashable {
case minimize
}

/// The ways that searchable modifiers can show or hide search scopes.
public struct SearchScopeActivation : Equatable {
let rawValue: Int

/// Scopes show while searching, as on iOS.
public static let automatic = SearchScopeActivation(rawValue: 0)
public static let onTextEntry = SearchScopeActivation(rawValue: 1)
public static let onSearchPresentation = SearchScopeActivation(rawValue: 2)
}

/// A type that specifies how search suggestions display.
public struct SearchSuggestionsPlacement : Equatable {
let rawValue: Int

public static let automatic = SearchSuggestionsPlacement(rawValue: 0)
public static let menu = SearchSuggestionsPlacement(rawValue: 1)
public static let content = SearchSuggestionsPlacement(rawValue: 2)

public struct Set : OptionSet, Equatable {
public let rawValue: Int

public init(rawValue: Int) {
self.rawValue = rawValue
}

public static let menu = SearchSuggestionsPlacement.Set(rawValue: 1 << 0)
public static let content = SearchSuggestionsPlacement.Set(rawValue: 1 << 1)
}
}

#if SKIP
let searchFieldHeight = 56.0

Expand All @@ -146,6 +262,7 @@ let searchFieldHeight = 56.0
}
}
let keyboardActions = KeyboardActions(submitState)
let scopes = state.scopes ?? EnvironmentValues.shared._searchScopes
let currentText = state.text.wrappedValue
let defaultTextFieldValue = TextFieldValue(text: currentText, selection: TextRange(currentText.count))
let textFieldValue = remember { mutableStateOf(defaultTextFieldValue) }
Expand All @@ -164,12 +281,14 @@ let searchFieldHeight = 56.0
state.isSearching.value = false
}
}
Row(horizontalArrangement: Arrangement.spacedBy(8.dp), verticalAlignment: androidx.compose.ui.Alignment.CenterVertically, modifier: context.modifier) {
Column(modifier: context.modifier) {
Row(horizontalArrangement: Arrangement.spacedBy(8.dp), verticalAlignment: androidx.compose.ui.Alignment.CenterVertically) {
let isFocused = remember { mutableStateOf(false) }
OutlinedTextField(value: currentTextFieldValue, onValueChange: {
textFieldValue.value = $0
state.text.wrappedValue = $0.text
}, modifier: Modifier.weight(Float(1.0)).semantics { testTagsAsResourceId = true }.testTag("skip_ui_automation_search_field").focusRequester(focusRequester).onFocusChanged {
state.isFocused.value = $0.isFocused
if $0.isFocused {
state.isSearching.value = true
state.isPresented?.wrappedValue = true
Expand All @@ -195,16 +314,48 @@ let searchFieldHeight = 56.0
}
}
}
if let scopes {
AnimatedVisibility(visible: scopes.isVisible(isSearching: state.isSearching.value, text: state.text.wrappedValue)) {
Picker(selection: scopes.selection, content: { scopes.content }, label: { EmptyView() })
.pickerStyle(PickerStyle.segmented)
.padding(.top, 8.0)
.Compose(context: contentContext)
}
}
}
}

/// Search scopes set by `searchScopes`.
struct SearchScopes {
let selection: Binding<Any>
let content: ComposeBuilder
let activation: SearchScopeActivation

func isVisible(isSearching: Bool, text: String) -> Bool {
return activation == .onTextEntry ? !text.isEmpty : isSearching
}
}

/// Marks a search suggestion that completes the search text.
final class SearchCompletionModifier: RenderModifier {
let completion: String

init(completion: String) {
self.completion = completion
super.init()
}
}

final class SearchableModifier: ModifierProtocol {
let text: Binding<String>
let prompt: Text?
let placement: SearchFieldPlacement
let isPresented: Binding<Bool>?

init(text: Binding<String>, prompt: Text?, isPresented: Binding<Bool>? = nil) {
init(text: Binding<String>, prompt: Text?, placement: SearchFieldPlacement = .automatic, isPresented: Binding<Bool>? = nil) {
self.text = text
self.prompt = prompt
self.placement = placement
self.isPresented = isPresented
}

Expand All @@ -216,6 +367,7 @@ final class SearchableModifier: ModifierProtocol {
let isSearching = rememberSaveable(stateSaver: context.stateSaver as! Saver<Bool, Any>) {
mutableStateOf(isPresented?.wrappedValue == true)
}
let isFocused = remember { mutableStateOf(false) }
let renderables = EnvironmentValues.shared.setValuesWithReturn {
$0.set_isSearching(isSearching)
return ComposeResult.ok
Expand All @@ -224,7 +376,7 @@ final class SearchableModifier: ModifierProtocol {
}
var ret: kotlin.collections.MutableList<Renderable> = mutableListOf()
for i in 0..<renderables.size {
ret.add(ModifiedContent(content: renderables[i], modifier: SearchableStateModifier(text: text, prompt: prompt, isPresented: isPresented, isSearching: isSearching, isFirstRenderable: i == 0)))
ret.add(ModifiedContent(content: renderables[i], modifier: SearchableStateModifier(text: text, prompt: prompt, placement: placement, isPresented: isPresented, isSearching: isSearching, isFocused: isFocused, isFirstRenderable: i == 0)))
}
return ret
}
Expand All @@ -235,7 +387,7 @@ final class SearchableModifier: ModifierProtocol {
}

final class SearchableStateModifier: RenderModifier {
init(text: Binding<String>, prompt: Text?, isPresented: Binding<Bool>?, isSearching: MutableState<Bool>, isFirstRenderable: Bool) {
init(text: Binding<String>, prompt: Text?, placement: SearchFieldPlacement, isPresented: Binding<Bool>?, isSearching: MutableState<Bool>, isFocused: MutableState<Bool>, isFirstRenderable: Bool) {
super.init()
self.action = { renderable, context in
let submitState = EnvironmentValues.shared._onSubmitState
Expand All @@ -245,7 +397,7 @@ final class SearchableStateModifier: RenderModifier {
// so isNavigationRoot is not yet true. Treat "modifier on NavigationStack" as on-stack
// so only Navigation shows the search bar; ScrollView/List/etc. must not show a second one.
let isOnNavigationStack = isModifierOnNavigationStack || isNavigationRoot
let state = SearchableState(text: text, prompt: prompt, submitState: submitState, isPresented: isPresented, isSearching: isSearching, isOnNavigationStack: isOnNavigationStack)
let state = SearchableState(text: text, prompt: prompt, submitState: submitState, isPresented: isPresented, isSearching: isSearching, isOnNavigationStack: isOnNavigationStack, isFocused: isFocused, isAlwaysVisible: placement.isAlwaysVisible, scopes: EnvironmentValues.shared._searchScopes)
// Bubble the search state to the navigation stack if root, else down to the component
if isModifierOnNavigationStack || isNavigationRoot != true {
EnvironmentValues.shared.setValues {
Expand Down Expand Up @@ -280,6 +432,12 @@ struct SearchableState: Equatable {
let isPresented: Binding<Bool>?
let isSearching: MutableState<Bool>
let isOnNavigationStack: Bool
/// Whether the search field has focus, which shows search suggestions.
let isFocused: MutableState<Bool>
/// Whether the search field stays visible while content scrolls.
let isAlwaysVisible: Bool
/// Scopes set outside the `searchable` modifier.
let scopes: SearchScopes?

static func ==(lhs: SearchableState, rhs: SearchableState) -> Bool {
// Most of this state can't be compared, and search bars handle the mutability internally
Expand Down
Loading
Loading