summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj8
-rw-r--r--ios/MullvadVPN/View controllers/SelectLocation/LocationViewControllerWrapper.swift49
-rw-r--r--ios/MullvadVPN/Views/ScaledSegmentedControl.swift61
-rw-r--r--ios/MullvadVPN/Views/SegmentedControl.swift78
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)
+}