Skip to content

Commit

Permalink
improves animations for sheets (#46)
Browse files Browse the repository at this point in the history
* improves animations for sheets (#45)

* Improves `sheet` presentation

* Adds `restart` feature for `CoordinatorType`

* Update tests

* Updates example

* Decrease swift-tools-version from `6.0` to `5.9`
  • Loading branch information
felilo authored Dec 23, 2024
1 parent 873d849 commit d34a975
Show file tree
Hide file tree
Showing 17 changed files with 300 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ struct FullscreenView: View {
.font(.largeTitle)

VStack {
Button("Presents FullscreenView") {
Task { await viewModel.presentFullscreen() }
}.buttonStyle(.borderedProminent)

Button("Presents SheetView") {
Task { await viewModel.presentSheetView() }
}.buttonStyle(.borderedProminent)

Button("Presents DetentsView") {
Task { await viewModel.navigateToNextView() }
Task { await viewModel.presentDetentsView() }
}.buttonStyle(.borderedProminent)

Button("Close view") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,23 @@ class FullscreenViewModel: ObservableObject {
self.coordinator = coordinator
}

@MainActor func navigateToNextView() async {
@MainActor func presentDetentsView() async {
await coordinator.presentDetents()
}

@MainActor func presentFullscreen() async {
await coordinator.presentFullscreen()
}

@MainActor func close() async {
await coordinator.close()
}

@MainActor func presentSheetView() async {
await coordinator.presentSheet()
}

@MainActor func finish() async {
await coordinator.finish()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,22 @@ struct PushView: View {
Text("Time: \(counter)")

VStack {
Button("navigate to PushView") {
Task { await viewModel.navigateToPushView() }
}.buttonStyle(.borderedProminent)

Button("Presents SheetView") {
Task { await viewModel.navigateToNextView() }
}.buttonStyle(.borderedProminent)

Button("Presents FullscreenView") {
Task { await viewModel.presentFullscreen() }
}.buttonStyle(.borderedProminent)

Button("Presents DetentsView") {
Task { await viewModel.presentDetentsView() }
}.buttonStyle(.borderedProminent)

Button("Close view") {
Task { await viewModel.close() }
}.buttonStyle(.borderedProminent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ class PushViewModel: ObservableObject {
await coordinator.presentSheet()
}

@MainActor func presentFullscreen() async {
await coordinator.presentFullscreen()
}

@MainActor func presentDetentsView() async {
await coordinator.presentDetents()
}

@MainActor func navigateToPushView() async {
await coordinator.navigateToPushView()
}

@MainActor func close() async {
await coordinator.close()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ struct SheetView: View {
typealias ViewModel = SheetViewModel

@StateObject var viewModel: ViewModel
@State private var counter = 0

let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

var body: some View {
ZStack {
Expand All @@ -38,18 +41,32 @@ struct SheetView: View {
VStack {
Text("Hello, SheetView!")
.font(.largeTitle)
Text("Time: \(counter)")

VStack {
Button("Presents FullscreenView") {
Task { await viewModel.navigateToNextView() }
}.buttonStyle(.borderedProminent)

Button("Presents SheetView") {
Task { await viewModel.presentSheetView() }
}.buttonStyle(.borderedProminent)

Button("Presents DetentsView") {
Task { await viewModel.presentDetentsView() }
}.buttonStyle(.borderedProminent)

Button("Close view") {
Task { await viewModel.close() }
}.buttonStyle(.borderedProminent)

Button("Restart coordinator") {
Task { await viewModel.restart() }
}.buttonStyle(.borderedProminent)
}
}
}
.onReceive(timer) { _ in counter += 1 }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,28 @@ class SheetViewModel: ObservableObject {
self.coordinator = coordinator
}


@MainActor func navigateToPushView() async {
await coordinator.navigateToPushView()
}

@MainActor func navigateToNextView() async {
await coordinator.presentFullscreen()
}

@MainActor func presentSheetView() async {
await coordinator.presentSheet()
}

@MainActor func presentDetentsView() async {
await coordinator.presentDetents()
}

@MainActor func close() async {
await coordinator.close()
}

@MainActor func restart() async {
await coordinator.restart(animated: true)
}
}
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:6.0
// swift-tools-version:5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,17 @@ public extension CoordinatorType {
///
/// - Parameters:
/// - animated: A boolean value indicating whether to animate the finish flow process.
@MainActor func finishFlow(animated: Bool = true) async -> Void {
func finishFlow(animated: Bool = true) async -> Void {
await finish(animated: animated, withDismiss: true)
}


/// Starts a flow in the coordinator with a specified route and transition style.
///
/// - Parameters:
/// - route: The route to start the flow.
/// - transitionStyle: The transition presentation style for the flow.
/// - animated: A boolean value indicating whether to animate the start flow process.
@MainActor func startFlow(route: Route, transitionStyle: TransitionPresentationStyle? = nil, animated: Bool = true) async -> Void {
func startFlow(route: Route, transitionStyle: TransitionPresentationStyle? = nil, animated: Bool = true) async -> Void {
router.mainView = route
}

Expand All @@ -93,4 +92,12 @@ public extension CoordinatorType {
let topCoordinator = try mainCoordinator?.topCoordinator()
await topCoordinator?.navigate(to: self, presentationStyle: presentationStyle)
}

/// Restarts the current view or coordinator, optionally animating the restart.
///
/// - Parameters:
/// - animated: A boolean value indicating whether to animate the restart action.
func restart(animated: Bool = true) async {
await router.restart(animated: animated)
}
}
2 changes: 0 additions & 2 deletions Sources/SUICoordinator/Router/Router+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ extension RouterType {
continuation.resume()
}
}

try? await Task.sleep(for: .seconds(animated ? 0.2 : 0))
}

/// Removes all `nil` items from the sheet coordinator.
Expand Down
18 changes: 7 additions & 11 deletions Sources/SUICoordinator/Router/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public class Router<Route: RouteType>: ObservableObject, RouterType {
}

let item = SheetItem(
id: view.id,
id: "\(view.id) - \(UUID())",
animated: animated,
presentationStyle: presentationStyle ?? view.presentationStyle,
view: { view.view }
Expand Down Expand Up @@ -172,6 +172,7 @@ public class Router<Route: RouteType>: ObservableObject, RouterType {
@MainActor public func close(animated: Bool = true, finishFlow: Bool = false) async -> Void {
if !sheetCoordinator.items.isEmpty {
await dismiss(animated: animated)
try? await Task.sleep(for: .seconds(animated ? 0.2 : 1))
} else if !items.isEmpty {
await pop(animated: animated)
}
Expand All @@ -184,26 +185,21 @@ public class Router<Route: RouteType>: ObservableObject, RouterType {
/// - withMainView: A boolean value indicating whether to clean the main view.
@MainActor public func clean(animated: Bool, withMainView: Bool = true) async -> Void {
await popToRoot(animated: false)
items.removeAll()
await sheetCoordinator.clean()
sheetCoordinator = .init()

if withMainView {
mainView = nil
}

if withMainView { mainView = nil }
}

/// Restarts the current view or coordinator, optionally animating the restart.
///
/// - Parameters:
/// - animated: A boolean value indicating whether to animate the restart action.
@MainActor public func restart(animated: Bool) async -> Void {
if !sheetCoordinator.items.isEmpty {
await pop(animated: false)
await sheetCoordinator.clean()
} else {
if sheetCoordinator.items.isEmpty {
await popToRoot(animated: animated)
} else {
await popToRoot(animated: false)
sheetCoordinator = .init()
}
}

Expand Down
6 changes: 4 additions & 2 deletions Sources/SUICoordinator/Router/RouterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ struct RouterView<Router: RouterType>: View {
.sheetCoordinator(
coordinator: viewModel.sheetCoordinator,
onDissmis: { index in
viewModel.removeItemFromSheetCoordinator(at: index)
viewModel.removeNilItemsFromSheetCoordinator()
Task(priority: .high) { @MainActor [weak viewModel] in
viewModel?.removeItemFromSheetCoordinator(at: index)
viewModel?.removeNilItemsFromSheetCoordinator()
}
},
onDidLoad: { _ in
viewModel.removeNilItemsFromSheetCoordinator()
Expand Down
63 changes: 50 additions & 13 deletions Sources/SUICoordinator/SheetCoordinator/SheetCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,32 @@ final public class SheetCoordinator<T>: ObservableObject {
// ---------------------------------------------------------

/// The stack of sheet items managed by the coordinator.
///
/// Each item in the stack is an optional `SheetItem`. This allows handling cases where
/// certain items might need to be removed or temporarily set to `nil`.
@Published var items: [Item?]

/// The presentation style of the last presented sheet.
///
/// This property is updated whenever a new sheet is presented. It reflects the most recent
/// `TransitionPresentationStyle` used in the presentation.
public private(set) var lastPresentationStyle: TransitionPresentationStyle?

/// The presentation style of the last presented sheet.
/// A boolean value indicating whether the last sheet presentation was animated.
///
/// This property is updated whenever a new sheet is presented, capturing whether
/// the transition to the presented sheet was animated or not.
public private(set) var animated: Bool?

/// A backup dictionary storing item-related data, where the key is an `Int` identifier
/// and the value is a `String` representing additional metadata for the sheet item.
private var backUpItems: [Int: String] = [:]

/// A closure that is invoked when a sheet item is removed from the stack.
///
/// The closure receives a `String` value representing the identifier or metadata
/// associated with the removed item. This can be used to handle clean-up operations
/// or perform additional tasks upon item removal.
var onRemoveItem: ((String) -> Void)?

// ---------------------------------------------------------
Expand Down Expand Up @@ -102,7 +118,7 @@ final public class SheetCoordinator<T>: ObservableObject {
guard !items.isEmpty else { return }
self.animated = animated
lastPresentationStyle = items.last(where: { $0?.presentationStyle != nil })??.presentationStyle
await makeNilItem(at: totalItems)
await makeNilItem(at: totalItems, animated: animated)
}

/// Removes the item at the specified index.
Expand All @@ -125,12 +141,29 @@ final public class SheetCoordinator<T>: ObservableObject {
/// - Parameters:
/// - animated: A boolean value indicating whether to animate the cleanup process.
@MainActor func clean(animated: Bool = true) async -> Void {
await makeNilItem(at: 0)
await makeNilItem(at: 0, animated: animated)
lastPresentationStyle = nil
items.removeAll()
backUpItems.removeAll()
}

/// Returns the next index based on the given index.
///
/// - Parameter index: The current index.
/// - Returns: The next index incremented by 1.
func getNextIndex(_ index: Int) -> Int {
index + 1
}

/// Checks whether the given index is the last index in the items array.
///
/// - Parameter index: The current index.
/// - Returns: A boolean value indicating whether the given index is the last index
/// or if the items array is empty.
func isLastIndex(_ index: Int) -> Bool {
items.isEmpty || index == totalItems
}

// ---------------------------------------------------------
// MARK: Private helper funcs
// ---------------------------------------------------------
Expand All @@ -144,20 +177,24 @@ final public class SheetCoordinator<T>: ObservableObject {
///
/// - Parameters:
/// - index: The index at which to remove `nil` items.
@MainActor private func makeNilItem(at index: Int) async {
@MainActor private func makeNilItem(at index: Int, animated: Bool) async {
guard isValidIndex(index) else { return }

for dIndex in items.indices {
if dIndex > index {
items[dIndex] = nil
}
}

items[index] = nil
try? await Task.sleep(for: .seconds(animated ? 0.3 : 0))
}

func getNextIndex(_ index: Int) -> Int {
index + 1
}

func isLastIndex(_ index: Int) -> Bool {
items.isEmpty || index == totalItems
}

func isValidIndex(_ index: Int) -> Bool {
/// Validates whether the given index is within the bounds of the items array.
///
/// - Parameter index: The index to validate.
/// - Returns: A boolean value indicating whether the index is valid and within the bounds.
private func isValidIndex(_ index: Int) -> Bool {
!items.isEmpty && items.indices.contains(index)
}
}
1 change: 0 additions & 1 deletion Sources/SUICoordinator/Tabbar/TabbarCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ open class TabbarCoordinator<Page: TabbarPage>: TabbarCoordinatable {
@MainActor public func clean() async {
await setPages([], currentPage: nil)
await router.clean(animated: false)
router = .init()
customView = nil
}
}
Loading

0 comments on commit d34a975

Please sign in to comment.