diff options
| author | Bug Magnet <marco.nikic@mullvad.net> | 2025-09-03 16:18:43 +0200 |
|---|---|---|
| committer | Bug Magnet <marco.nikic@mullvad.net> | 2025-09-10 11:39:22 +0200 |
| commit | e9d1241c079c95f5f59444d09665e3367bba3ddd (patch) | |
| tree | db5bf64282ab295b68cd85ec3248f2b1feec5a38 | |
| parent | e76d2cf8a791c318b2357d781bbcda23d605d214 (diff) | |
| download | mullvadvpn-e9d1241c079c95f5f59444d09665e3367bba3ddd.tar.xz mullvadvpn-e9d1241c079c95f5f59444d09665e3367bba3ddd.zip | |
Rewrite the multihop segmented control in SwiftUI
4 files changed, 104 insertions, 92 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index b5c06eb70b..5c53868bd4 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -744,6 +744,7 @@ 85F1E17E2C0A256200DB8F55 /* LeakTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F1E17D2C0A256200DB8F55 /* LeakTests.swift */; }; 85FB5A0C2B6903990015DCED /* WelcomePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0B2B6903990015DCED /* WelcomePage.swift */; }; 85FB5A102B6960A30015DCED /* AccountDeletionPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85FB5A0F2B6960A30015DCED /* AccountDeletionPage.swift */; }; + A9019ED72E6878CD0002ACA9 /* SegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9019ED62E6878CD0002ACA9 /* SegmentedControl.swift */; }; A902E7A62D3FB0D9007F844A /* LogFileOutputStreamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A902E7A52D3FB0D9007F844A /* LogFileOutputStreamTests.swift */; }; A90763B02B2857D50045ADF0 /* Socks5ConnectCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90763A02B2857D50045ADF0 /* Socks5ConnectCommand.swift */; }; A90763B12B2857D50045ADF0 /* Socks5Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90763A12B2857D50045ADF0 /* Socks5Endpoint.swift */; }; @@ -1040,7 +1041,6 @@ F09D04C02AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BF2AF39D63003D4F89 /* OutgoingConnectionServiceTests.swift */; }; F09D04C12AF39EA2003D4F89 /* OutgoingConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09D04BC2AEBB7C5003D4F89 /* OutgoingConnectionService.swift */; }; F0A086902C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A0868F2C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift */; }; - F0A7AAA22E1D31E200D433E8 /* ScaledSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A7AAA12E1D31E200D433E8 /* ScaledSegmentedControl.swift */; }; F0A7EBB22CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A7EBB12CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift */; }; F0A7EBB62CF092CC005BB671 /* ApplicationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BFA5CB22A7CE1F00A6173D /* ApplicationConfiguration.swift */; }; F0A89CB32D9D6C2100580C27 /* MullvadDeviceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A89CB22D9D6C1400580C27 /* MullvadDeviceProxy.swift */; }; @@ -2322,6 +2322,7 @@ A900E9BB2ACC609200C95F67 /* DevicesProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DevicesProxy+Stubs.swift"; sourceTree = "<group>"; }; A900E9BD2ACC654100C95F67 /* APIProxy+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIProxy+Stubs.swift"; sourceTree = "<group>"; }; A900E9BF2ACC661900C95F67 /* AccessTokenManager+Stubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessTokenManager+Stubs.swift"; sourceTree = "<group>"; }; + A9019ED62E6878CD0002ACA9 /* SegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedControl.swift; sourceTree = "<group>"; }; A902E7A52D3FB0D9007F844A /* LogFileOutputStreamTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFileOutputStreamTests.swift; sourceTree = "<group>"; }; A90763A02B2857D50045ADF0 /* Socks5ConnectCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Socks5ConnectCommand.swift; sourceTree = "<group>"; }; A90763A12B2857D50045ADF0 /* Socks5Endpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Socks5Endpoint.swift; sourceTree = "<group>"; }; @@ -2503,7 +2504,6 @@ F0A0868F2C22D6A700BF83E7 /* TunnelSettingsStrategyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsStrategyTests.swift; sourceTree = "<group>"; }; F0A163882C47B46300592300 /* SingleHopEphemeralPeerExchangerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleHopEphemeralPeerExchangerTests.swift; sourceTree = "<group>"; }; F0A66C992E4A3607006F190A /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; }; - F0A7AAA12E1D31E200D433E8 /* ScaledSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaledSegmentedControl.swift; sourceTree = "<group>"; }; F0A7EBB12CEF6C79005BB671 /* ConsolidatedApplicationLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsolidatedApplicationLogTests.swift; sourceTree = "<group>"; }; F0A89CB22D9D6C1400580C27 /* MullvadDeviceProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadDeviceProxy.swift; sourceTree = "<group>"; }; F0A89CB62D9D922300580C27 /* String+UnsafePointer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+UnsafePointer.swift"; sourceTree = "<group>"; }; @@ -3396,12 +3396,12 @@ F91B94A62DC9EB5E00132C28 /* MullvadInfoHeaderView.swift */, F91CCBFB2DFAF5E1007F1925 /* MullvadProgressViewStyle.swift */, 7A8A190F2CEE3918000BCB5B /* RowSeparator.swift */, - F0A7AAA12E1D31E200D433E8 /* ScaledSegmentedControl.swift */, 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */, 7AA130A02D01B1E200640DF9 /* SplitMainButton.swift */, E1FD0DF428AA7CE400299DB4 /* StatusActivityView.swift */, 58EF581025D69DB400AEBA94 /* StatusImageView.swift */, 7AA1309E2D007B2500640DF9 /* VisualEffectView.swift */, + A9019ED62E6878CD0002ACA9 /* SegmentedControl.swift */, ); path = Views; sourceTree = "<group>"; @@ -6652,6 +6652,7 @@ 58677712290976FB006F721F /* SettingsInteractor.swift in Sources */, 58EF875D2B1638BF00C098B2 /* ProxyConfigurationTesterProtocol.swift in Sources */, 58CE5E66224146200008646E /* LoginViewController.swift in Sources */, + A9019ED72E6878CD0002ACA9 /* SegmentedControl.swift in Sources */, F048BFA22D31843000251CB9 /* ChangeLogModel.swift in Sources */, F0C6FA852A6A733700F521F0 /* InAppPurchaseInteractor.swift in Sources */, 58CEB2F92AFD136E00E6E088 /* UIBackgroundConfiguration+Extensions.swift in Sources */, @@ -6777,7 +6778,6 @@ A99E5EE22B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift in Sources */, 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */, 586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */, - F0A7AAA22E1D31E200D433E8 /* ScaledSegmentedControl.swift in Sources */, 581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */, A99E5EE02B7628150033F241 /* ProblemReportViewModel.swift in Sources */, F910A4312D4A1B41002FF3BB /* InAppPurchaseCoordinator.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift index 51775153c5..59c8467d8e 100644 --- a/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift +++ b/ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift @@ -9,6 +9,7 @@ import MullvadREST import MullvadSettings import MullvadTypes +import SwiftUI import UIKit protocol LocationViewControllerWrapperDelegate: AnyObject { @@ -36,8 +37,9 @@ final class LocationViewControllerWrapper: UIViewController { private var entryLocationViewController: LocationViewController? private let exitLocationViewController: LocationViewController - private let segmentedControl = ScaledSegmentedControl() + private var segmentedControlView: UIView! private let locationViewContainer = UIView() + private var segmentedViewModel = SegmentedControlViewModel() private var settings: LatestTunnelSettings private var relaySelectorWrapper: RelaySelectorWrapper @@ -127,7 +129,7 @@ final class LocationViewControllerWrapper: UIViewController { entryLocationViewController?.setDaitaChip(isDirectOnly) entryLocationViewController?.toggleDaitaAutomaticRouting(isEnabled: isAutomaticRouting) } else { - segmentedControl.isHidden = true + segmentedControlView?.isHidden = true exitLocationViewController.setObfuscationChip(isObfuscation) exitLocationViewController.setDaitaChip(isDirectOnly) } @@ -182,33 +184,29 @@ final class LocationViewControllerWrapper: UIViewController { } private func setUpSegmentedControl() { - segmentedControl.backgroundColor = .SegmentedControl.backgroundColor - segmentedControl.selectedSegmentTintColor = .SegmentedControl.selectedColor - - segmentedControl.insertSegment( - withTitle: MultihopContext.entry.description, - at: MultihopContext.entry.rawValue, - animated: false - ) - segmentedControl.insertSegment( - withTitle: MultihopContext.exit.description, - at: MultihopContext.exit.rawValue, - animated: false + let swiftUISegmentedControl = SegmentedControl( + segments: MultihopContext.allCases.map { $0.description }, + viewModel: segmentedViewModel, + onSelectedSegment: segmentedControlDidChange ) - segmentedControl.selectedSegmentIndex = multihopContext.rawValue - segmentedControl.addTarget(self, action: #selector(segmentedControlDidChange), for: .valueChanged) + let host = UIHostingController(rootView: swiftUISegmentedControl) + addChild(host) + host.didMove(toParent: self) + segmentedControlView = host.view! + segmentedViewModel.selectedSegmentIndex = multihopContext.rawValue + host.view.backgroundColor = .clear } private func addSubviews() { - view.addConstrainedSubviews([segmentedControl, locationViewContainer]) { - segmentedControl.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) - segmentedControl.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .leading(8), .trailing(8)])) + view.addConstrainedSubviews([segmentedControlView, locationViewContainer]) { + segmentedControlView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44) + segmentedControlView.pinEdgesToSuperviewMargins(PinnableEdges([.top(0), .leading(8), .trailing(8)])) locationViewContainer.pinEdgesToSuperview(.all().excluding(.top)) if settings.tunnelMultihopState.isEnabled { - locationViewContainer.topAnchor.constraint(equalTo: segmentedControl.bottomAnchor, constant: 4) + locationViewContainer.topAnchor.constraint(equalTo: segmentedControlView.bottomAnchor, constant: 4) } else { locationViewContainer.pinEdgeToSuperviewMargin(.top(0)) } @@ -224,9 +222,8 @@ final class LocationViewControllerWrapper: UIViewController { } } - @objc - private func segmentedControlDidChange(sender: UISegmentedControl) { - multihopContext = .allCases[segmentedControl.selectedSegmentIndex] + private func segmentedControlDidChange(selectedIndex: Int) { + multihopContext = .allCases[selectedIndex] swapViewController() } @@ -281,10 +278,8 @@ extension LocationViewControllerWrapper: @preconcurrency LocationViewControllerD case .entry: selectedEntry = relays delegate?.didSelectEntryRelays(relays) - - // Trigger change in segmented control, which in turn triggers view controller swap. - segmentedControl.selectedSegmentIndex = MultihopContext.exit.rawValue - segmentedControl.sendActions(for: .valueChanged) + segmentedViewModel.selectedSegmentIndex = MultihopContext.exit.rawValue + segmentedControlDidChange(selectedIndex: MultihopContext.exit.rawValue) case .exit: delegate?.didSelectExitRelays(relays) didFinish?() diff --git a/ios/MullvadVPN/Views/ScaledSegmentedControl.swift b/ios/MullvadVPN/Views/ScaledSegmentedControl.swift deleted file mode 100644 index ab477a3be5..0000000000 --- a/ios/MullvadVPN/Views/ScaledSegmentedControl.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ScaledSegmentedControl.swift -// MullvadVPN -// -// Created by Mojgan on 2025-07-02. -// Copyright © 2025 Mullvad VPN AB. All rights reserved. -// -import UIKit - -final class ScaledSegmentedControl: UISegmentedControl { - private let textStyle: UIFont.TextStyle - private let fontWeight: UIFont.Weight - - init(textStyle: UIFont.TextStyle = .body, weight: UIFont.Weight = .regular) { - self.textStyle = textStyle - self.fontWeight = weight - super.init(frame: .zero) - applyTextAttributes() - subscribeToDynamicType() - } - - required init?(coder: NSCoder) { - self.textStyle = .body - self.fontWeight = .regular - super.init(coder: coder) - applyTextAttributes() - subscribeToDynamicType() - } - - override func insertSegment(withTitle title: String?, at segment: Int, animated: Bool) { - super.insertSegment(withTitle: title, at: segment, animated: animated) - applyTextAttributes() - } - - private func applyTextAttributes() { - let font = UIFont.preferredFont(forTextStyle: textStyle).withWeight(fontWeight) - let attributes: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: UIColor.primaryTextColor, - ] - setTitleTextAttributes(attributes, for: .normal) - setTitleTextAttributes(attributes, for: .selected) - } - - private func subscribeToDynamicType() { - NotificationCenter.default.addObserver( - self, - selector: #selector(contentSizeChanged), - name: UIContentSizeCategory.didChangeNotification, - object: nil - ) - } - - @objc private func contentSizeChanged() { - applyTextAttributes() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } -} diff --git a/ios/MullvadVPN/Views/SegmentedControl.swift b/ios/MullvadVPN/Views/SegmentedControl.swift new file mode 100644 index 0000000000..efee1328b5 --- /dev/null +++ b/ios/MullvadVPN/Views/SegmentedControl.swift @@ -0,0 +1,78 @@ +// +// SegmentedControl.swift +// MullvadVPN +// +// Created by Marco Nikic on 2025-09-03. +// Copyright © 2025 Mullvad VPN AB. All rights reserved. +// + +import SwiftUI + +class SegmentedControlViewModel: ObservableObject { + @Published var selectedSegmentIndex = 0 +} + +struct SegmentedControl<Segment: StringProtocol>: View { + var segments: [Segment] + @ObservedObject var viewModel: SegmentedControlViewModel + public var onSelectedSegment: ((Int) -> Void)? + + func isSelected(segment: Segment) -> Bool { + viewModel.selectedSegmentIndex == segments.firstIndex(of: segment) + } + + var body: some View { + GeometryReader { proxy in + HStack(spacing: 0) { + ForEach(segments, id: \.self) { segment in + // The segments are expected to be already localised + Text(segment) + .font(.mullvadSmallSemiBold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) // Makes the text take all the available space + .contentShape(Rectangle()) // Makes the tappable area extend beyond just the text + .onTapGesture { + withAnimation(.easeInOut(duration: 0.25)) { + viewModel.selectedSegmentIndex = segments.firstIndex(of: segment)! + onSelectedSegment?(viewModel.selectedSegmentIndex) + } + } + .background( + Group { + if isSelected(segment: segment) { + Capsule() + .fill(UIColor.SegmentedControl.selectedColor.color) + .frame(height: 36) + } else { + Capsule() + .fill(UIColor.SegmentedControl.backgroundColor.color) + .frame(height: 36) + } + } + ) + } + } + .padding([.leading, .trailing], 4) // Insets the inner shape to not overlay with the outer one + .frame(maxWidth: .infinity, maxHeight: proxy.size.height) + .background( + Capsule(style: .circular) + .fill(UIColor.SegmentedControl.backgroundColor.color) + ) + .clipShape(Capsule()) + } + } +} + +#Preview { + VStack { + Spacer() + SegmentedControl( + segments: ["Entry", "Exit"], + viewModel: SegmentedControlViewModel(), + onSelectedSegment: { newIndex in print("Selected \(newIndex)") } + ) + .frame(height: 44) + Spacer() + } + .background(Color.mullvadBackground) +} |
