Created
April 4, 2026 12:35
-
-
Save rursache/d0644d7955f8f108f66ec42cfce948f6 to your computer and use it in GitHub Desktop.
Custom UISegmentControl with arbitrary views as the content
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import UIKit | |
| /// A `UISegmentedControl` subclass that displays custom views as segment content. | |
| /// | |
| /// Segments are initialized with unique empty reference images. During `layoutSubviews()`, | |
| /// the view hierarchy is walked to find the `UIImageView` matching each reference image, | |
| /// and the custom view is installed as a sibling in that image view's superview. | |
| public final class CustomSegmentedControl: UISegmentedControl { | |
| // MARK: - Properties | |
| private var referenceImages: [UIImage] = [] | |
| private var segmentViews: [UIView?] = [] | |
| private var cachedContainers: [UIView?] = [] | |
| // MARK: - Public API | |
| /// Sets up the segments with empty reference images. | |
| public func configureSegments(count: Int, selectedSegment: Int = 0) { | |
| removeAllSegments() | |
| referenceImages = [] | |
| segmentViews = Array(repeating: nil, count: count) | |
| cachedContainers = Array(repeating: nil, count: count) | |
| for index in 0..<count { | |
| let image = createReferenceImage() | |
| referenceImages.append(image) | |
| insertSegment(with: image, at: index, animated: false) | |
| } | |
| selectedSegmentIndex = selectedSegment | |
| setNeedsLayout() | |
| } | |
| /// Associates a custom view with a segment index. | |
| public func setSegmentView(_ view: UIView, at index: Int) { | |
| guard index < segmentViews.count else { return } | |
| segmentViews[index] = view | |
| cachedContainers[index] = nil | |
| setNeedsLayout() | |
| } | |
| // MARK: - Layout | |
| /// Disables the default intrinsic height so the control accepts | |
| /// external sizing from SwiftUI's `.frame(height:)`. | |
| public override var intrinsicContentSize: CGSize { | |
| var size = super.intrinsicContentSize | |
| size.height = UIView.noIntrinsicMetric | |
| return size | |
| } | |
| public override func layoutSubviews() { | |
| super.layoutSubviews() | |
| // Force all immediate subviews to fill the control's height | |
| for subview in subviews { | |
| subview.frame.size.height = bounds.height | |
| subview.frame.origin.y = 0 | |
| } | |
| installSegmentViews() | |
| } | |
| // MARK: - Private | |
| private func createReferenceImage() -> UIImage { | |
| let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) | |
| return renderer.image { _ in | |
| UIColor.clear.setFill() | |
| UIBezierPath(rect: CGRect(x: 0, y: 0, width: 1, height: 1)).fill() | |
| } | |
| } | |
| private func installSegmentViews() { | |
| for index in referenceImages.indices { | |
| guard let view = segmentViews[index] else { continue } | |
| // Use cached container or find it by walking the view hierarchy | |
| let container: UIView | |
| if let cached = cachedContainers[index] { | |
| container = cached | |
| } else { | |
| guard let imageView = findImageView(for: referenceImages[index], in: self), | |
| let parent = imageView.superview else { continue } | |
| imageView.isHidden = true | |
| cachedContainers[index] = parent | |
| container = parent | |
| } | |
| if view.superview !== container { | |
| view.translatesAutoresizingMaskIntoConstraints = false | |
| container.addSubview(view) | |
| NSLayoutConstraint.activate([ | |
| view.centerXAnchor.constraint(equalTo: container.centerXAnchor), | |
| view.centerYAnchor.constraint(equalTo: container.centerYAnchor), | |
| ]) | |
| } | |
| } | |
| } | |
| private func findImageView(for referenceImage: UIImage, in view: UIView) -> UIImageView? { | |
| if let imageView = view as? UIImageView, imageView.image === referenceImage { | |
| return imageView | |
| } | |
| for subview in view.subviews { | |
| if let found = findImageView(for: referenceImage, in: subview) { | |
| return found | |
| } | |
| } | |
| return nil | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| struct ExampleView: View { | |
| @State private var selection = 0 | |
| @State private var prices = ["€3.99", "€24.99", "€179.00"] | |
| var body: some View { | |
| NavigationStack { | |
| VStack(spacing: 32) { | |
| SegmentedControl(selection: $selection, items: Array(prices.indices)) { index in | |
| VStack(spacing: 2) { | |
| Text(prices[index]) | |
| .font(.body.weight(.bold)) | |
| .contentTransition(.numericText()) | |
| .fixedSize() | |
| Text("per month") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| } | |
| .frame(height: 60) | |
| .padding(.horizontal) | |
| Button("Randomize Prices") { | |
| withAnimation(.easeInOut) { | |
| prices = [ | |
| "€\(String(format: "%.2f", Double.random(in: 1...9)))", | |
| "€\(String(format: "%.2f", Double.random(in: 10...49)))", | |
| "€\(String(format: "%.2f", Double.random(in: 100...299)))" | |
| ] | |
| } | |
| } | |
| Spacer() | |
| } | |
| .padding(.top) | |
| .navigationTitle("Demo") | |
| .navigationBarTitleDisplayMode(.inline) | |
| } | |
| } | |
| } | |
| #Preview { | |
| ExampleView() | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| based on | |
| https://twitter.com/sebjvidal/status/2040077923797844056 | |
| https://twitter.com/sebjvidal/status/2040079214381076844 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| @MainActor | |
| private final class SegmentModel<Content: View>: ObservableObject { | |
| @Published var content: Content | |
| init(_ content: Content) { self.content = content } | |
| } | |
| private struct SegmentHost<Content: View>: View { | |
| @ObservedObject var model: SegmentModel<Content> | |
| var body: some View { | |
| model.content | |
| } | |
| } | |
| /// A SwiftUI wrapper around `CustomSegmentedControl` that displays arbitrary SwiftUI views | |
| /// as segment content inside a `UISegmentedControl`. | |
| public struct SegmentedControl<Data: RandomAccessCollection, Content: View>: UIViewRepresentable where Data.Index == Int { | |
| @Binding public var selection: Int | |
| public let items: Data | |
| @ViewBuilder public let content: (Data.Element) -> Content | |
| public init( | |
| selection: Binding<Int>, | |
| items: Data, | |
| @ViewBuilder content: @escaping (Data.Element) -> Content | |
| ) { | |
| self._selection = selection | |
| self.items = items | |
| self.content = content | |
| } | |
| public func makeCoordinator() -> Coordinator { | |
| Coordinator(self) | |
| } | |
| public func makeUIView(context: Context) -> CustomSegmentedControl { | |
| let control = CustomSegmentedControl() | |
| control.addTarget( | |
| context.coordinator, | |
| action: #selector(Coordinator.selectionChanged(_:)), | |
| for: .valueChanged | |
| ) | |
| control.setContentHuggingPriority(.defaultLow, for: .vertical) | |
| control.setContentCompressionResistancePriority(.defaultLow, for: .vertical) | |
| let itemsArray = Array(items) | |
| control.configureSegments(count: itemsArray.count, selectedSegment: selection) | |
| context.coordinator.createHostingControllers(for: control, items: itemsArray) | |
| return control | |
| } | |
| public func updateUIView(_ control: CustomSegmentedControl, context: Context) { | |
| let itemsArray = Array(items) | |
| context.coordinator.parent = self | |
| if control.numberOfSegments != itemsArray.count { | |
| context.coordinator.cleanup() | |
| control.configureSegments(count: itemsArray.count, selectedSegment: selection) | |
| context.coordinator.createHostingControllers(for: control, items: itemsArray) | |
| } else { | |
| let animation = context.transaction.animation | |
| for index in itemsArray.indices where index < context.coordinator.models.count { | |
| let model = context.coordinator.models[index] | |
| let newContent = content(itemsArray[index]) | |
| withAnimation(animation) { | |
| model.content = newContent | |
| } | |
| } | |
| } | |
| if control.selectedSegmentIndex != selection { | |
| control.selectedSegmentIndex = selection | |
| } | |
| } | |
| // MARK: - Coordinator | |
| @MainActor | |
| public final class Coordinator: NSObject { | |
| var parent: SegmentedControl | |
| fileprivate var models: [SegmentModel<Content>] = [] | |
| private var hostingControllers: [UIHostingController<SegmentHost<Content>>] = [] | |
| init(_ parent: SegmentedControl) { | |
| self.parent = parent | |
| } | |
| func createHostingControllers(for control: CustomSegmentedControl, items: [Data.Element]) { | |
| for index in items.indices { | |
| let model = SegmentModel(parent.content(items[index])) | |
| models.append(model) | |
| let hc = UIHostingController(rootView: SegmentHost(model: model)) | |
| hc.view.backgroundColor = .clear | |
| hc.view.isUserInteractionEnabled = false | |
| hostingControllers.append(hc) | |
| control.setSegmentView(hc.view, at: index) | |
| } | |
| } | |
| func cleanup() { | |
| for hc in hostingControllers { | |
| hc.view.removeFromSuperview() | |
| } | |
| hostingControllers.removeAll() | |
| models.removeAll() | |
| } | |
| @objc func selectionChanged(_ sender: UISegmentedControl) { | |
| parent.selection = sender.selectedSegmentIndex | |
| } | |
| } | |
| } |
Author
rursache
commented
Apr 4, 2026

Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment