Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Intercept back button when leaving an unsaved custom list
Browse files Browse the repository at this point in the history
Jon Petersson committed Apr 11, 2024
1 parent b2c94cc commit a895d07
Showing 15 changed files with 243 additions and 78 deletions.
4 changes: 4 additions & 0 deletions ios/MullvadVPN.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
@@ -539,6 +539,7 @@
7A6F2FAB2AFD3097006D0856 /* CustomDNSCellFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */; };
7A6F2FAD2AFD3DA7006D0856 /* CustomDNSViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */; };
7A6F2FAF2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */; };
7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */; };
7A7AD28D29DC677800480EF1 /* FirstTimeLaunch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */; };
7A818F1F29F0305800C7F0F4 /* RootConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */; };
7A83A0C62B29A750008B5CE7 /* APIAccessMethodsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */; };
@@ -1803,6 +1804,7 @@
7A6F2FAA2AFD3097006D0856 /* CustomDNSCellFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSCellFactory.swift; sourceTree = "<group>"; };
7A6F2FAC2AFD3DA7006D0856 /* CustomDNSViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDNSViewController.swift; sourceTree = "<group>"; };
7A6F2FAE2AFE36E7006D0856 /* VPNSettingsInfoButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNSettingsInfoButtonItem.swift; sourceTree = "<group>"; };
7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptibleNavigationController.swift; sourceTree = "<group>"; };
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstTimeLaunch.swift; sourceTree = "<group>"; };
7A818F1E29F0305800C7F0F4 /* RootConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootConfiguration.swift; sourceTree = "<group>"; };
7A83A0C52B29A750008B5CE7 /* APIAccessMethodsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAccessMethodsTests.swift; sourceTree = "<group>"; };
@@ -2692,6 +2694,7 @@
58138E60294871C600684F0C /* DeviceDataThrottling.swift */,
7A7AD28C29DC677800480EF1 /* FirstTimeLaunch.swift */,
582AE30F2440A6CA00E6733A /* InputTextFormatter.swift */,
7A7907322BC0280A00B61F81 /* InterceptibleNavigationController.swift */,
58DFF7D12B0256A300F864E0 /* MarkdownStylingOptions.swift */,
58CC40EE24A601900019D96E /* ObserverList.swift */,
);
@@ -5459,6 +5462,7 @@
7A42DEC92A05164100B209BE /* SettingsInputCell.swift in Sources */,
5803B4B22940A48700C23744 /* TunnelStore.swift in Sources */,
586A950F29012BEE007BAF2B /* AddressCacheTracker.swift in Sources */,
7A7907332BC0280A00B61F81 /* InterceptibleNavigationController.swift in Sources */,
F02F41A02B9723AF00625A4F /* AddLocationsViewController.swift in Sources */,
587B753D2666468F00DEF7E9 /* NotificationController.swift in Sources */,
);
36 changes: 36 additions & 0 deletions ios/MullvadVPN/Classes/InterceptibleNavigationController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// InterceptibleNavigationController.swift
// MullvadVPN
//
// Created by Jon Petersson on 2024-04-05.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import UIKit

class InterceptibleNavigationController: CustomNavigationController {
var shouldPopViewController: ((UIViewController) -> Bool)?
var shouldPopToViewController: ((UIViewController) -> Bool)?

// Called when popping the topmost view controller in the stack, eg. by pressing a navigation
// bar back button.
override func popViewController(animated: Bool) -> UIViewController? {
guard let viewController = viewControllers.last else { return nil }

if shouldPopViewController?(viewController) ?? true {
return super.popViewController(animated: animated)
} else {
return nil
}
}

// Called when popping to a specific view controller, eg. by long pressing a navigation bar
// back button (revealing a navigation menu) and selecting a destination view controller.
override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
if shouldPopToViewController?(viewController) ?? true {
return super.popToViewController(viewController, animated: animated)
} else {
return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -84,17 +84,10 @@ extension AddCustomListCoordinator: CustomListViewControllerDelegate {
let coordinator = AddLocationsCoordinator(
navigationController: navigationController,
nodes: nodes,
customList: list
subject: subject
)

coordinator.didFinish = { [weak self] locationsCoordinator, customList in
guard let self else { return }
subject.send(CustomListViewModel(
id: customList.id,
name: customList.name,
locations: customList.locations,
tableSections: subject.value.tableSections
))
coordinator.didFinish = { locationsCoordinator in
locationsCoordinator.removeFromParent()
}

Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import MullvadSettings
import MullvadTypes
import Routing
@@ -14,9 +15,9 @@ import UIKit
class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
private let navigationController: UINavigationController
private let nodes: [LocationNode]
private var customList: CustomList
private var subject: CurrentValueSubject<CustomListViewModel, Never>

var didFinish: ((AddLocationsCoordinator, CustomList) -> Void)?
var didFinish: ((AddLocationsCoordinator) -> Void)?

var presentedViewController: UIViewController {
navigationController
@@ -25,17 +26,17 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
init(
navigationController: UINavigationController,
nodes: [LocationNode],
customList: CustomList
subject: CurrentValueSubject<CustomListViewModel, Never>
) {
self.navigationController = navigationController
self.nodes = nodes
self.customList = customList
self.subject = subject
}

func start() {
let controller = AddLocationsViewController(
allLocationsNodes: nodes,
customList: customList
subject: subject
)
controller.delegate = self

@@ -51,11 +52,7 @@ class AddLocationsCoordinator: Coordinator, Presentable, Presenting {
}

extension AddLocationsCoordinator: AddLocationsViewControllerDelegate {
func didUpdateSelectedLocations(locations: [RelayLocation]) {
customList.locations = locations
}

func didBack() {
didFinish?(self, customList)
didFinish?(self)
}
}
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import MullvadSettings
import MullvadTypes
import UIKit
@@ -15,20 +16,21 @@ class AddLocationsDataSource:
LocationDiffableDataSourceProtocol {
private var customListLocationNode: CustomListLocationNode
private let nodes: [LocationNode]
var didUpdateCustomList: ((CustomListLocationNode) -> Void)?
private let subject: CurrentValueSubject<CustomListViewModel, Never>
let tableView: UITableView
let sections: [LocationSection]

init(
tableView: UITableView,
allLocationNodes: [LocationNode],
customList: CustomList
subject: CurrentValueSubject<CustomListViewModel, Never>
) {
self.tableView = tableView
self.nodes = allLocationNodes
self.subject = subject

self.customListLocationNode = CustomListLocationNodeBuilder(
customList: customList,
customList: subject.value.customList,
allLocations: self.nodes
).customListLocationNode

@@ -51,10 +53,12 @@ class AddLocationsDataSource:
reloadWithSelectedLocations()
}

// Called from `LocationDiffableDataSourceProtocol`.
func nodeShowsChildren(_ node: LocationNode) -> Bool {
isLocationInCustomList(node: node)
}

// Called from `LocationDiffableDataSourceProtocol`.
func nodeShouldBeSelected(_ node: LocationNode) -> Bool {
customListLocationNode.children.contains(node)
}
@@ -149,7 +153,10 @@ extension AddLocationsDataSource: LocationCellDelegate {
customListLocationNode.remove(selectedLocation: item.node, with: locationList)
}
updateDataSnapshot(with: [locationList], completion: {
self.didUpdateCustomList?(self.customListLocationNode)
let locations = self.customListLocationNode.children.reduce([]) { partialResult, locationNode in
partialResult + locationNode.locations
}
self.subject.value.locations = locations
})
}
}
Original file line number Diff line number Diff line change
@@ -6,19 +6,19 @@
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import MullvadSettings
import MullvadTypes
import UIKit

protocol AddLocationsViewControllerDelegate: AnyObject {
func didUpdateSelectedLocations(locations: [RelayLocation])
func didBack()
}

class AddLocationsViewController: UIViewController {
private var dataSource: AddLocationsDataSource?
private let nodes: [LocationNode]
private let customList: CustomList
private let subject: CurrentValueSubject<CustomListViewModel, Never>

weak var delegate: AddLocationsViewControllerDelegate?
private let tableView: UITableView = {
@@ -33,10 +33,10 @@ class AddLocationsViewController: UIViewController {

init(
allLocationsNodes: [LocationNode],
customList: CustomList
subject: CurrentValueSubject<CustomListViewModel, Never>
) {
self.nodes = allLocationsNodes
self.customList = customList
self.subject = subject
super.init(nibName: nil, bundle: nil)
}

@@ -70,17 +70,8 @@ class AddLocationsViewController: UIViewController {
dataSource = AddLocationsDataSource(
tableView: tableView,
allLocationNodes: nodes.copy(),
customList: customList
subject: subject
)

dataSource?.didUpdateCustomList = { [weak self] customListLocationNode in
guard let self else { return }
delegate?.didUpdateSelectedLocations(
locations: customListLocationNode.children.reduce([]) { partialResult, locationNode in
partialResult + locationNode.locations
}
)
}
}
}

101 changes: 101 additions & 0 deletions ios/MullvadVPN/Coordinators/CustomLists/CustomListViewController.swift
Original file line number Diff line number Diff line change
@@ -27,6 +27,14 @@ class CustomListViewController: UIViewController {
private let alertPresenter: AlertPresenter
private var validationErrors: Set<CustomListFieldValidationError> = []

private var persistedCustomList: CustomList? {
return interactor.fetchAll().first(where: { $0.id == subject.value.id })
}

private var customListHasUnsavedChanges: Bool {
return persistedCustomList != subject.value.customList
}

private lazy var cellConfiguration: CustomListCellConfiguration = {
CustomListCellConfiguration(tableView: tableView, subject: subject)
}()
@@ -91,9 +99,38 @@ class CustomListViewController: UIViewController {
}

private func configureNavigationItem() {
if let navigationController = navigationController as? InterceptibleNavigationController {
interceptNavigation(navigationController)
}

navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationItem.rightBarButtonItem = saveBarButton
}

private func interceptNavigation(_ navigationController: InterceptibleNavigationController) {
navigationController.shouldPopViewController = { [weak self] viewController in
guard
let self,
viewController is Self,
customListHasUnsavedChanges
else { return true }

self.onUnsavedChanges()
return false
}

navigationController.shouldPopToViewController = { [weak self] viewController in
guard
let self,
viewController is ListCustomListViewController,
customListHasUnsavedChanges
else { return true }

self.onUnsavedChanges()
return false
}
}

private func configureTableView() {
tableView.delegate = dataSourceConfiguration
tableView.backgroundColor = .secondaryColor
@@ -195,4 +232,68 @@ class CustomListViewController: UIViewController {

alertPresenter.showAlert(presentation: presentation, animated: true)
}

@objc private func onUnsavedChanges() {
let message = NSMutableAttributedString(
markdownString: NSLocalizedString(
"CUSTOM_LISTS_UNSAVED_CHANGES_PROMPT",
tableName: "CustomLists",
value: "You have unsaved changes.",
comment: ""
),
options: MarkdownStylingOptions(font: .preferredFont(forTextStyle: .body))
)

let presentation = AlertPresentation(
id: "api-custom-lists-unsaved-changes-alert",
icon: .alert,
attributedMessage: message,
buttons: [
AlertAction(
title: NSLocalizedString(
"CUSTOM_LISTS_DISCARD_CHANGES_BUTTON",
tableName: "CustomLists",
value: "Discard changes",
comment: ""
),
style: .destructive,
handler: {
// Reset subject/view model to no longer having unsaved changes.
if let persistedCustomList = self.persistedCustomList {
self.subject.value.update(with: persistedCustomList)
}
self.delegate?.customListDidSave(self.subject.value.customList)
}
),
AlertAction(
title: NSLocalizedString(
"CUSTOM_LISTS_BACK_TO_EDITING_BUTTON",
tableName: "CustomLists",
value: "Back to editing",
comment: ""
),
style: .default
),
]
)

alertPresenter.showAlert(presentation: presentation, animated: true)
}
}

extension CustomListViewController: UIGestureRecognizerDelegate {
// For some reason, intercepting `popViewController(animated: Bool)` in `InterceptibleNavigationController`
// by SWIPING back leads to weird behaviour where subsequent navigation seem to happen systemwise but not
// UI-wise. This leads to the UI freezing up, and the only remedy is to restart the app.
//
// To get around this issue we can intercept the back swipe gesture and manually perform the transtion
// instead, thereby bypassing the inner mechanisms that go out of sync.
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == navigationController?.interactivePopGestureRecognizer else {
return true
}

navigationController?.popViewController(animated: true)
return false
}
}
Original file line number Diff line number Diff line change
@@ -18,4 +18,9 @@ struct CustomListViewModel {
var customList: CustomList {
CustomList(id: id, name: name, locations: locations)
}

mutating func update(with list: CustomList) {
name = list.name
locations = list.locations
}
}
Loading

0 comments on commit a895d07

Please sign in to comment.