Skip to content

Commit

Permalink
Merge pull request #61 from hotwired/additional-modal-presentation-st…
Browse files Browse the repository at this point in the history
…yles

Additional modal presentation styles
  • Loading branch information
svara authored Dec 17, 2024
2 parents 3cea3b0 + 2d3ca34 commit ed93291
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 52 deletions.
75 changes: 75 additions & 0 deletions Source/Turbo/Navigator/Extensions/PathPropertiesExtensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
public extension PathProperties {
var context: Navigation.Context {
guard let rawValue = self["context"] as? String,
let context = Navigation.Context(rawValue: rawValue) else {
return .default
}

return context
}

var presentation: Navigation.Presentation {
guard let rawValue = self["presentation"] as? String,
let presentation = Navigation.Presentation(rawValue: rawValue) else {
return .default
}

return presentation
}

var modalStyle: Navigation.ModalStyle {
guard let rawValue = self["modal_style"] as? String,
let modalStyle = Navigation.ModalStyle(rawValue: rawValue) else {
return .large
}

return modalStyle
}

var pullToRefreshEnabled: Bool {
self["pull_to_refresh_enabled"] as? Bool ?? true
}

var modalDismissGestureEnabled: Bool {
self["modal_dismiss_gesture_enabled"] as? Bool ?? true
}

/// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern.
///
/// For example, given the following configuration file:
///
/// ```json
/// {
/// "rules": [
/// {
/// "patterns": [
/// "/recipes/*"
/// ],
/// "properties": {
/// "view_controller": "recipes",
/// }
/// }
/// ]
/// }
/// ```
///
/// A VisitProposal to `https://example.com/recipes/` will have
/// ```swift
/// proposal.viewController == "recipes"
/// ```
///
/// - Important: A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`.
/// - Note: A `ViewController` must conform to `PathConfigurationIdentifiable` to couple the identifier with a view controlelr.
var viewController: String {
guard let viewController = self["view_controller"] as? String else {
return VisitableViewController.pathConfigurationIdentifier
}

return viewController
}

/// Allows the proposal to change the animation status when pushing, popping or presenting.
var animated: Bool {
self["animated"] as? Bool ?? true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ extension UINavigationController {
modalPresentationStyle = .automatic
case .full:
modalPresentationStyle = .fullScreen
case .pageSheet:
modalPresentationStyle = .pageSheet
case .formSheet:
modalPresentationStyle = .formSheet
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import UIKit

extension UIViewController {
func configureModalBehaviour(with proposal: VisitProposal) {
isModalInPresentation = !proposal.modalDismissGestureEnabled
}
}
60 changes: 10 additions & 50 deletions Source/Turbo/Navigator/Extensions/VisitProposalExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,30 @@ import UIKit

public extension VisitProposal {
var context: Navigation.Context {
if let rawValue = properties["context"] as? String {
return Navigation.Context(rawValue: rawValue) ?? .default
}
return .default
properties.context
}

var presentation: Navigation.Presentation {
if let rawValue = properties["presentation"] as? String {
return Navigation.Presentation(rawValue: rawValue) ?? .default
}
return .default
properties.presentation
}

var modalStyle: Navigation.ModalStyle {
if let rawValue = properties["modal_style"] as? String {
return Navigation.ModalStyle(rawValue: rawValue) ?? .large
}
return .large
properties.modalStyle
}

var pullToRefreshEnabled: Bool {
properties["pull_to_refresh_enabled"] as? Bool ?? true
properties.pullToRefreshEnabled
}

/// Used to identify a custom native view controller if provided in the path configuration properties of a given pattern.
///
/// For example, given the following configuration file:
///
/// ```json
/// {
/// "rules": [
/// {
/// "patterns": [
/// "/recipes/*"
/// ],
/// "properties": {
/// "view_controller": "recipes",
/// }
/// }
/// ]
/// }
/// ```
///
/// A VisitProposal to `https://example.com/recipes/` will have
/// ```swift
/// proposal.viewController == "recipes"
/// ```
///
/// - Important: A default value is provided in case the view controller property is missing from the configuration file. This will route the default `VisitableViewController`.
/// - Note: A `ViewController` must conform to `PathConfigurationIdentifiable` to couple the identifier with a view controlelr.
var viewController: String {
if let viewController = properties["view_controller"] as? String {
return viewController
}
var modalDismissGestureEnabled: Bool {
properties.modalDismissGestureEnabled
}

return VisitableViewController.pathConfigurationIdentifier
var viewController: String {
properties.viewController
}

/// Allows the proposal to change the animation status when pushing, popping or presenting.
var animated: Bool {
if let animated = properties["animated"] as? Bool {
return animated
}

return true
properties.animated
}
}
2 changes: 2 additions & 0 deletions Source/Turbo/Navigator/Helpers/Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@ public enum Navigation {
case medium
case large
case full
case pageSheet
case formSheet
}
}
4 changes: 4 additions & 0 deletions Source/Turbo/Navigator/NavigationHierarchyController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ class NavigationHierarchyController {
if let visitable = controller as? Visitable {
delegate.visit(visitable, on: .modal, with: proposal.options)
}
controller.configureModalBehaviour(with: proposal)

if navigationController.presentedViewController != nil, !modalNavigationController.isBeingDismissed {
pushOrReplace(on: modalNavigationController, with: controller, via: proposal)
} else {
Expand Down Expand Up @@ -160,6 +162,8 @@ class NavigationHierarchyController {
if let visitable = controller as? Visitable {
delegate.visit(visitable, on: .modal, with: proposal.options)
}
controller.configureModalBehaviour(with: proposal)

if navigationController.presentedViewController != nil {
modalNavigationController.replaceLastViewController(with: controller)
} else {
Expand Down
60 changes: 60 additions & 0 deletions Tests/Turbo/Fixtures/test-modal-styles-configuration.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"rules":[
{
"patterns":[
"/new$"
],
"properties":{
"context":"modal",
"modal_dismiss_gesture_enabled": false
}
},
{
"patterns":[
"/newMedium$"
],
"properties":{
"context":"modal",
"modal_style":"medium",
"modal_dismiss_gesture_enabled": true
}
},
{
"patterns":[
"/newLarge$"
],
"properties":{
"background_color":"black",
"context":"modal",
"modal_style":"large"
}
},
{
"patterns":[
"/newFull$"
],
"properties":{
"context":"modal",
"modal_style":"full"
}
},
{
"patterns":[
"/newPageSheet$"
],
"properties":{
"context":"modal",
"modal_style":"pageSheet"
}
},
{
"patterns":[
"/newFormSheet$"
],
"properties":{
"context":"modal",
"modal_style":"formSheet"
}
}
]
}
63 changes: 61 additions & 2 deletions Tests/Turbo/Navigator/NavigationHierarchyControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,59 @@ final class NavigationHierarchyControllerTests: XCTestCase {
/// No assertions needed. App will crash if we pass a non-http or non-https scheme to SFSafariViewController.
}

func test_modalStyle_isCorrectlySet() throws {
let proposal = VisitProposal(
path: "/new",
context: .modal,
additionalProperties: [
"modal_style": "formSheet"
])
navigator.route(proposal)
XCTAssertEqual(modalNavigationController.modalPresentationStyle, .formSheet)
}

func test_noModalStyle_defaultsToAutomatic() throws {
let proposal = VisitProposal(
path: "/new",
context: .modal
)
navigator.route(proposal)
// NOTE: For most view controllers, UIKit maps `automatic` style to the UIModalPresentationStyle.pageSheet style,
// but some system view controllers may map it to a different style.
XCTAssertEqual(modalNavigationController.modalPresentationStyle, .pageSheet)
}

func test_modalDismissGestureEnabled_isCorrectlySet() throws {
let proposal = VisitProposal(
path: "/new",
context: .modal,
additionalProperties: [
"modal_dismiss_gesture_enabled": true
])
navigator.route(proposal)
XCTAssertEqual(modalNavigationController.visibleViewController?.isModalInPresentation, false)
}

func test_modalDismissGestureDisabled_isCorrectlySet() throws {
let proposal = VisitProposal(
path: "/new",
context: .modal,
additionalProperties: [
"modal_dismiss_gesture_enabled": false
])
navigator.route(proposal)
XCTAssertEqual(modalNavigationController.visibleViewController?.isModalInPresentation, true)
}

func test_modalDismissGestureEnabled_missing_defaultsToTrue() throws {
let proposal = VisitProposal(
path: "/new",
context: .modal
)
navigator.route(proposal)
XCTAssertEqual(modalNavigationController.visibleViewController?.isModalInPresentation, false)
}

// MARK: Private

private enum Context {
Expand Down Expand Up @@ -370,13 +423,19 @@ private class EmptyNavigationDelegate: NavigationHierarchyControllerDelegate {
// MARK: - VisitProposal extension

private extension VisitProposal {
init(path: String = "", action: VisitAction = .advance, context: Navigation.Context = .default, presentation: Navigation.Presentation = .default) {
init(path: String = "",
action: VisitAction = .advance,
context: Navigation.Context = .default,
presentation: Navigation.Presentation = .default,
additionalProperties: [String: AnyHashable] = [:]) {
let url = URL(string: "https://example.com")!.appendingPathComponent(path)
let options = VisitOptions(action: action, response: nil)
let properties: PathProperties = [
let defaultProperties: PathProperties = [
"context": context.rawValue,
"presentation": presentation.rawValue
]
let properties = defaultProperties.merging(additionalProperties) { (current, _) in current }

self.init(url: url, options: options, properties: properties)
}
}
Expand Down
Loading

0 comments on commit ed93291

Please sign in to comment.