Last active
April 18, 2026 18:09
-
-
Save saschagordner/a33b5efc9f1179bc1bb3713b8b95bbf2 to your computer and use it in GitHub Desktop.
Expanding Segmented Control with Liquid Glass
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
| // This gist shows how to use a `UISegmentedControl` to achieve the goal of having expandable segment items, | |
| // where the active segment would show a label, while the others do not. | |
| // Since `UISegmentedControl` only supports images *or* text for their items, we draw a custom image for the items, | |
| // which is basically a HStack with a label and an optional label. | |
| // I've used fixed widths for the items since otherwise the whole segment item would animate, leading to funky image | |
| // resizing effects. | |
| // This only serves as a demo and is not meant to be production ready. | |
| final class SampleVc: UIViewController { | |
| private enum Constants { | |
| static let collapsedWidth: CGFloat = 72 | |
| static let expandedWidth: CGFloat = 140 | |
| static let animationDuration: TimeInterval = 0.35 | |
| } | |
| private let segmentedControl = UISegmentedControl(items: ["", "", ""]) | |
| override func viewDidLoad() { | |
| super.viewDidLoad() | |
| segmentedControl.selectedSegmentIndex = 0 | |
| segmentedControl.addTarget(self, action: #selector(segmentChanged(_:)), for: .valueChanged) | |
| view.addSubview(segmentedControl) | |
| applySegmentAppearance(selectedIndex: 0, animated: false) | |
| } | |
| @objc | |
| private func segmentChanged(_ sender: UISegmentedControl) { | |
| applySegmentAppearance(selectedIndex: sender.selectedSegmentIndex, animated: true) | |
| } | |
| private func applySegmentAppearance(selectedIndex: Int, animated: Bool) { | |
| // Swap images without animation to avoid glitches during the width animation. Calling `layoutIfNeeded` inside | |
| // the no-animation block ensures this. | |
| UIView.performWithoutAnimation { | |
| for index in 0..<self.segmentedControl.numberOfSegments { | |
| let label = index == selectedIndex ? self.selectedLabel(for: index) : nil | |
| self.segmentedControl.setImage(self.makeSegmentImage(label: label), forSegmentAt: index) | |
| } | |
| self.segmentedControl.layoutIfNeeded() | |
| } | |
| // Animate width changes. Calling `layoutIfNeeded` inside the animation block ensures the width changes are | |
| // animated, since we pass this closure to UIView.animate. | |
| let widthUpdates = { | |
| for index in 0..<self.segmentedControl.numberOfSegments { | |
| let targetWidth = index == selectedIndex ? Constants.expandedWidth : Constants.collapsedWidth | |
| self.segmentedControl.setWidth(targetWidth, forSegmentAt: index) | |
| } | |
| self.segmentedControl.layoutIfNeeded() | |
| } | |
| if animated { | |
| UIView.animate( | |
| withDuration: Constants.animationDuration, | |
| delay: 0, | |
| usingSpringWithDamping: 0.8, | |
| initialSpringVelocity: 0.15, | |
| options: [.curveEaseInOut], | |
| animations: widthUpdates | |
| ) | |
| } else { | |
| UIView.performWithoutAnimation(widthUpdates) | |
| } | |
| } | |
| private func selectedLabel(for index: Int) -> String? { | |
| switch index { | |
| case 0: return "Miniatures" | |
| case 1: return "Recipes" | |
| case 2: return "Paints" | |
| default: return nil | |
| } | |
| } | |
| /// Renders a segment image which is basically a HStack with a SFSymbol (star) and an optional label. | |
| private func makeSegmentImage(label: String?) -> UIImage { | |
| let symbolConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) | |
| let icon = UIImage(systemName: "star.fill", withConfiguration: symbolConfig)! | |
| .withTintColor(.label, renderingMode: .alwaysOriginal) | |
| let spacing: CGFloat = 6 | |
| let horizontalPadding: CGFloat = 8 | |
| let verticalPadding: CGFloat = 4 | |
| let textAttributes: [NSAttributedString.Key: Any] = [ | |
| .font: UIFont.systemFont(ofSize: 13, weight: .medium), | |
| .foregroundColor: UIColor.label | |
| ] | |
| let textSize: CGSize | |
| if let label, !label.isEmpty { | |
| textSize = (label as NSString).size(withAttributes: textAttributes) | |
| } else { | |
| textSize = .zero | |
| } | |
| let width = horizontalPadding * 2 + icon.size.width + (textSize == .zero ? 0 : spacing + textSize.width) | |
| let height = verticalPadding * 2 + max(icon.size.height, textSize.height) | |
| let imageSize = CGSize(width: ceil(width), height: ceil(height)) | |
| return UIGraphicsImageRenderer(size: imageSize).image { _ in | |
| let contentHeight = max(icon.size.height, textSize.height) | |
| let originY = (imageSize.height - contentHeight) / 2 | |
| var x = horizontalPadding | |
| icon.draw( | |
| in: CGRect( | |
| x: x, | |
| y: originY + (contentHeight - icon.size.height) / 2, | |
| width: icon.size.width, | |
| height: icon.size.height | |
| ) | |
| ) | |
| if let label, !label.isEmpty { | |
| x += icon.size.width + spacing | |
| let textRect = CGRect( | |
| x: x, | |
| y: originY + (contentHeight - textSize.height) / 2, | |
| width: textSize.width, | |
| height: textSize.height | |
| ) | |
| (label as NSString).draw(in: textRect, withAttributes: textAttributes) | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment