Customizing Segmented Control in SwiftUI and UIKit

Clara Muniz
Poatek
Published in
5 min readAug 12, 2024

Sometimes, customizing native components can be challenging. One component that gave me a hard time was the SegmentedControl known as Picker in SwiftUI. I encountered difficulty while implementing the component with rounded corners. Achieving this with UIKit was already complicated, but it proved to be even more complex with SwiftUI.

To make things easier for myself and anyone interested, I want to share the code for implementation. As you may know, if you’re familiar with SwiftUI and UIKit, UIKit offers more convenience. So, let’s start with the UIKit code.

⚠️ I didn't implement good architectural patterns on this code, so let’s focus only on the customization.

You will need to create a class that inherits the properties of UISegmentedControl. Following that, you must override the function layoutSubviews to customize the appearance. In order to achieve a rounded background with a different color, you can implement the following code:

class SegmentedControl: UISegmentedControl {
override func layoutSubviews() {
super.layoutSubviews()

//background
layer.cornerRadius = bounds.height/2
layer.backgroundColor = UIColor.purple.cgColor
}
}

Now, the segmented selection needs to be rounded:

class SegmentedControl: UISegmentedControl {
override func layoutSubviews() {
super.layoutSubviews()
//background
layer.cornerRadius = bounds.height/2
layer.backgroundColor = UIColor.purple.cgColor

//foreground
let foregroundIndex = numberOfSegments
if subviews.indices.contains(foregroundIndex), let foregroundImageView = subviews[foregroundIndex] as? UIImageView {
foregroundImageView.bounds = foregroundImageView.bounds.insetBy(dx: CGFloat(foregroundIndex), dy: CGFloat(foregroundIndex))
foregroundImageView.image = UIImage(color: .systemPink)
foregroundImageView.layer.removeAnimation(forKey: "SelectionBounds")
foregroundImageView.layer.masksToBounds = true
foregroundImageView.layer.cornerRadius = foregroundImageView.bounds.height/2
}
}
}

Xcode will probably generate an error with UIImage(color: .systemPink). This occurs because an extension from UIImage is required to apply colors like images. Therefore, you need to add the following code in a separate file:

//creates a UIImage given a UIColor
extension UIImage {
public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) {
let rect = CGRect(origin: .zero, size: size)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
color.setFill()
UIRectFill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let cgImage = image?.cgImage else { return nil }
self.init(cgImage: cgImage)
}
}

Now, you have to add the component on UIViewController:

class ViewController: UIViewController {

let segmentedControl: SegmentedControl = {
let items = ["First", "Second", "Third"]
let control = SegmentedControl(items: items)
control.selectedSegmentIndex = 0
return control
}()

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.

// Add the segmented control to the view
view.addSubview(segmentedControl)

// Set the constraints for the segmented control
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
segmentedControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
segmentedControl.centerYAnchor.constraint(equalTo: view.centerYAnchor),
segmentedControl.widthAnchor.constraint(equalToConstant: 300),
segmentedControl.heightAnchor.constraint(equalToConstant: 50)
])
}
}

You should see your Segmented Control as:

Segmented Control - UIKit

As I mentioned before, customizing components on SwiftUI is more challenging. This task requires the use of the UIViewRepresentable protocol. If you are unfamiliar with this protocol, you can find more information about it here.

I developed this another code with usability for larger projects, where the component can be utilized in multiple locations with different colors. Our primary struct, named CustomSegmentedControl, conforms to the UIViewRepresentableprotocol, allowing a UIKit view to be used as a SwiftUI view.

Within this struct, there is a property named data of type SegmentedControlData. This property stores the essential data needed to set up the segmented control, including the options and all requisite colors.

struct SegmentedControlData {
var segmentOptions: [String]
var selectedSegmentForegroundColor: UIColor
var selectedSegmentBackgroundColor: UIColor
var normalSegmentForegroundColor: UIColor
var normalSegmentBackgroundColor: UIColor
var borderSegmentColor: UIColor
}
struct CustomSegmentedControl: UIViewRepresentable {
var data: SegmentedControlData
@Binding var seletedSegment: Int

func makeUIView(context: Context) -> UISegmentedControl {
let segmentedControl =
UIKitSegmentedControl(
backgroundPickerColor: data.normalSegmentBackgroundColor,
borderPickerColor: data.borderSegmentColor,
foregroundColor: data.selectedSegmentBackgroundColor)
segmentedControl.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged)

print(seletedSegment)

let normalAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 18),
.foregroundColor: data.normalSegmentForegroundColor
]

let selectedAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 18),
.foregroundColor: data.selectedSegmentForegroundColor
]

segmentedControl.setTitleTextAttributes(normalAttributes, for: .normal)
segmentedControl.setTitleTextAttributes(selectedAttributes, for: .selected)

for (index, option) in data.segmentOptions.enumerated() {
segmentedControl.insertSegment(withTitle: option, at: index, animated: false)
}
return segmentedControl
}

func updateUIView(_ uiView: UISegmentedControl, context: Context) {
uiView.selectedSegmentIndex = seletedSegment
}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}
}

extension CustomSegmentedControl {
class Coordinator: NSObject {
var control: CustomSegmentedControl
init(_ control: CustomSegmentedControl) {
self.control = control
}
@objc func valueChanged(_ sender: UISegmentedControl) {
control.seletedSegment = sender.selectedSegmentIndex
}
}
}

To conform with the protocol, two functions must be implemented:

(1) The makeUIView function generates a picker using UIKit and the provided data. This function also configures other component attributes, such as text formatting and an action method. The action method is responsible for updating the selectedSegment binding in the parent view. This is achieved by creating an internal Coordinator class that serves as a delegate for the segmented control. The coordinator ensures that the selectedSegment binding is updated whenever the value of the segmented control changes.

(2) The other function, updateUIView, is responsible for updating the selected segment index of the segmented control whenever the selectedSegment binding changes in the SwiftUI view hierarchy.

Xcode will generate an error with UIKitSegmentedControl because it does not exist. So, you must implement the following code:

class UIKitSegmentedControl: UISegmentedControl {
private let segmentInset: CGFloat = 4
var backgroundPickerColor: UIColor
var borderPickerColor: UIColor
var foregroundColor: UIColor

init(backgroundPickerColor: UIColor, borderPickerColor: UIColor, foregroundColor: UIColor) {
self.backgroundPickerColor = backgroundPickerColor
self.borderPickerColor = borderPickerColor
self.foregroundColor = foregroundColor
super.init(frame: .zero)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func layoutSubviews() {
super.layoutSubviews()

//background
layer.cornerRadius = bounds.height/2
self.backgroundColor = backgroundPickerColor
self.frame.size.height = 50.0
self.layer.cornerRadius = 25
self.layer.borderWidth = 2
self.layer.borderColor = borderPickerColor.cgColor

//foreground
let foregroundIndex = numberOfSegments
if subviews.indices.contains(foregroundIndex), let foregroundImageView = subviews[foregroundIndex] as? UIImageView {
foregroundImageView.bounds = foregroundImageView.bounds.insetBy(dx: segmentInset, dy: segmentInset)
foregroundImageView.image = UIImage(color: foregroundColor)
foregroundImageView.layer.removeAnimation(forKey: "SelectionBounds")
foregroundImageView.layer.masksToBounds = true
foregroundImageView.layer.cornerRadius = foregroundImageView.bounds.height/2
}
}
}

Now, we only need to:

(1) Create the enum that will be used to inject the data at the struct CustomSegmentedControl;

enum SegmentedControlEnum {
case profile

var themeColors: SegmentedControlData {
switch self {
case .profile:
let theme = SegmentedControlData(
segmentOptions: ["First", "Second", "Third"],
selectedSegmentForegroundColor: UIColor(.green),
selectedSegmentBackgroundColor: UIColor(.white),
normalSegmentForegroundColor: UIColor(.gray),
normalSegmentBackgroundColor: UIColor(.gray.opacity(0.5)),
borderSegmentColor: UIColor(.gray.opacity(0.5)))
return theme
}
}
}

(2) Create the UIImage extension to turn a color into an image (the same code as before);

extension UIImage {

//creates a UIImage given a UIColor
public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) {
let rect = CGRect(origin: .zero, size: size)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
color.setFill()
UIRectFill(rect)
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
guard let cgImage = image?.cgImage else { return nil }
self.init(cgImage: cgImage)
}
}

(3) Initialize the view on a screen.

struct ProfileView: View {
@State var seletedSegment = 0
var body: some View {
CustomSegmentedControl(
data: SegmentedControlEnum.profile.themeColors,
seletedSegment: $seletedSegment)
}
}

If you did not change the colors and used the same code, you should see your Segmented Control like this:

Segmented Control — SwiftUI

--

--

Clara Muniz
Poatek
0 Followers
Editor for

Student at Universidade Federal do ABC pursuing an interdisciplinary bachelor degree in Science and Technology (BC&T) and employee of Poatek.