Skip to content

Instantly share code, notes, and snippets.

@rursache
Created April 4, 2026 12:35
Show Gist options
  • Select an option

  • Save rursache/d0644d7955f8f108f66ec42cfce948f6 to your computer and use it in GitHub Desktop.

Select an option

Save rursache/d0644d7955f8f108f66ec42cfce948f6 to your computer and use it in GitHub Desktop.
Custom UISegmentControl with arbitrary views as the content
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
}
}
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()
}
based on
https://twitter.com/sebjvidal/status/2040077923797844056
https://twitter.com/sebjvidal/status/2040079214381076844
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
}
}
}
@rursache
Copy link
Copy Markdown
Author

rursache commented Apr 4, 2026

demo

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