-
Notifications
You must be signed in to change notification settings - Fork 120
Offline banners for views with cached data #5000
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d4adc5a
5601042
b21d196
2ee175f
fbf1c12
686cf54
f55c281
0e48831
895dc4c
decf9e4
415bd82
0ead06f
6607671
f791ad5
0bd6643
45dc949
9d52ffe
fb4de9a
221eae9
37ab5b4
b9b5a85
315001a
edaf329
c54bce8
d32c80c
3c62701
912d117
c742b27
2eee06b
263e532
db4fcc6
1ce862e
0033684
7530df4
06994f4
c8ca97e
407f229
ebc44ef
e739597
cc626f8
cc2f90c
67aa0de
4fabfb7
2408211
53456ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import UIKit | ||
| import Combine | ||
|
|
||
| extension UIViewController { | ||
| /// Defines if the view controller should show a "no connection" banner when offline. | ||
| /// This requires the view controller to be contained inside a `WooNavigationController`. | ||
| /// Defaults to `false`. | ||
| /// | ||
| @objc var shouldShowOfflineBanner: Bool { | ||
| false | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import Combine | ||
| import UIKit | ||
|
|
||
| /// Subclass to set Woo styling. Removes back button text on managed view controllers. | ||
|
|
@@ -38,13 +39,25 @@ class WooNavigationController: UINavigationController { | |
| /// | ||
| private class WooNavigationControllerDelegate: NSObject, UINavigationControllerDelegate { | ||
|
|
||
| private let connectivityObserver: ConnectivityObserver | ||
| private var currentController: UIViewController? | ||
| private var subscriptions: Set<AnyCancellable> = [] | ||
|
|
||
| init(connectivityObserver: ConnectivityObserver = ServiceLocator.connectivityObserver) { | ||
| self.connectivityObserver = connectivityObserver | ||
| super.init() | ||
| observeConnectivity() | ||
| } | ||
|
|
||
| /// Children delegate, all events will be forwarded to this object | ||
| /// | ||
| weak var forwardDelegate: UINavigationControllerDelegate? | ||
|
|
||
| /// Configures the back button for the managed `ViewController` and forwards the event to the children delegate. | ||
| /// | ||
| func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { | ||
| currentController = viewController | ||
| configureOfflineBanner(for: viewController) | ||
| configureBackButton(for: viewController) | ||
| forwardDelegate?.navigationController?(navigationController, willShow: viewController, animated: animated) | ||
| } | ||
|
|
@@ -76,3 +89,69 @@ private extension WooNavigationControllerDelegate { | |
| viewController.removeNavigationBackBarButtonText() | ||
| } | ||
| } | ||
|
|
||
| // MARK: Offline banner configuration | ||
| private extension WooNavigationControllerDelegate { | ||
|
|
||
| /// Observes changes in status of connectivity and updates the offline banner in current view controller accordingly. | ||
| /// | ||
| func observeConnectivity() { | ||
| connectivityObserver.statusPublisher | ||
| .sink { [weak self] status in | ||
| guard let self = self, let currentController = self.currentController else { return } | ||
| self.configureOfflineBanner(for: currentController, status: status) | ||
| } | ||
| .store(in: &subscriptions) | ||
| } | ||
|
|
||
| /// Shows or hides offline banner based on the input connectivity status and | ||
| /// whether the view controller supports showing the banner. | ||
| /// | ||
| func configureOfflineBanner(for viewController: UIViewController, status: ConnectivityStatus? = nil) { | ||
| if viewController.shouldShowOfflineBanner { | ||
| setOfflineBannerWhenNoConnection(for: viewController, status: status ?? connectivityObserver.currentStatus) | ||
| } else { | ||
| removeOfflineBanner(for: viewController) | ||
| } | ||
| } | ||
|
|
||
| /// Adds offline banner at the bottom of the view controller. | ||
| /// | ||
| func setOfflineBannerWhenNoConnection(for viewController: UIViewController, status: ConnectivityStatus) { | ||
| // We can only show it when we are sure we can't reach the internet | ||
| guard status == .notReachable else { | ||
| return removeOfflineBanner(for: viewController) | ||
| } | ||
|
|
||
| // Only add banner view if it's not already added. | ||
| guard let navigationController = viewController.navigationController, | ||
| let view = viewController.view, | ||
| view.subviews.first(where: { $0 is OfflineBannerView }) == nil else { | ||
| return | ||
| } | ||
|
|
||
| let offlineBannerView = OfflineBannerView(frame: .zero) | ||
| offlineBannerView.backgroundColor = .gray | ||
| offlineBannerView.translatesAutoresizingMaskIntoConstraints = false | ||
| view.addSubview(offlineBannerView) | ||
|
|
||
| let extraBottomSpace = viewController.hidesBottomBarWhenPushed ? navigationController.view.safeAreaInsets.bottom : 0 | ||
| NSLayoutConstraint.activate([ | ||
| offlineBannerView.heightAnchor.constraint(equalToConstant: OfflineBannerView.height), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this is safe to do, would it all fit correctly if fonts grow bigger? Probably better to get that height dynamically with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have created a separate issue here #5041 to handle it later. |
||
| offlineBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), | ||
| offlineBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), | ||
| offlineBannerView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -extraBottomSpace) | ||
| ]) | ||
| viewController.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: OfflineBannerView.height, right: 0) | ||
| } | ||
|
|
||
| /// Removes the offline banner from the view controller if it exists. | ||
| /// | ||
| func removeOfflineBanner(for viewController: UIViewController) { | ||
| guard let offlineBanner = viewController.view.subviews.first(where: { $0 is OfflineBannerView }) else { | ||
| return | ||
| } | ||
| offlineBanner.removeFromSuperview() | ||
| viewController.additionalSafeAreaInsets = .zero | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import UIKit | ||
|
|
||
| /// Gray banner showing message when device is offline. | ||
| /// | ||
| final class OfflineBannerView: UIView { | ||
|
|
||
| static let height: CGFloat = 44 | ||
|
|
||
| override init(frame: CGRect) { | ||
| super.init(frame: frame) | ||
| setupView() | ||
| } | ||
|
|
||
| required init?(coder: NSCoder) { | ||
| fatalError("init(coder:) has not been implemented") | ||
| } | ||
|
|
||
| private func setupView() { | ||
| let stackView = UIStackView() | ||
| stackView.axis = .horizontal | ||
| stackView.spacing = 3 | ||
| stackView.distribution = .fillProportionally | ||
| stackView.alignment = .center | ||
| stackView.translatesAutoresizingMaskIntoConstraints = false | ||
|
|
||
| let imageView = UIImageView(image: .lightningImage) | ||
| imageView.tintColor = .white | ||
| imageView.contentMode = .scaleAspectFit | ||
| imageView.translatesAutoresizingMaskIntoConstraints = false | ||
| NSLayoutConstraint.activate([ | ||
| imageView.widthAnchor.constraint(equalToConstant: 24), | ||
| imageView.heightAnchor.constraint(equalToConstant: 24) | ||
| ]) | ||
|
|
||
| let messageLabel = UILabel() | ||
| messageLabel.text = NSLocalizedString("Offline - using cached data", comment: "Message for offline banner") | ||
| messageLabel.applyCalloutStyle() | ||
| messageLabel.textColor = .white | ||
| messageLabel.translatesAutoresizingMaskIntoConstraints = false | ||
|
|
||
| stackView.addArrangedSubview(imageView) | ||
| stackView.addArrangedSubview(messageLabel) | ||
|
|
||
| addSubview(stackView) | ||
| NSLayoutConstraint.activate([ | ||
| stackView.safeLeadingAnchor.constraint(greaterThanOrEqualTo: safeLeadingAnchor, constant: 0), | ||
| stackView.safeBottomAnchor.constraint(greaterThanOrEqualTo: safeBottomAnchor, constant: 0), | ||
| stackView.centerXAnchor.constraint(equalTo: centerXAnchor), | ||
| stackView.centerYAnchor.constraint(equalTo: centerYAnchor) | ||
| ]) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we have the banner as a
lazy varwe could later dobannerView.superView == nilright?I'm thinking that we could add the view to the navigation view but still modify the
additionalSafeAreaInsetsof the VC.Would that work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea of reusing the banner view, so I've tried your suggested solution but found a couple of issues:
removeConstraintson all constraints of the banner causes the banner to lose even its constraints to its sub views (the image and title inside). So we may need to keep references to the constraints to parent view to remove them later.https://user-images.githubusercontent.com/5533851/134460006-b8b7ed0a-a831-4ed0-84a0-0a84a6adf26d.MP4
So I'll keep the current solution until finding a better one.