Making a great UI and UX often requires making something unique and nice. This almost always requires us, as developers, in such cases to create a custom component.

For me, this is one of the most interesting parts of the development (more interesting is just fixing a strange bug ;]).

In this post I would like to tell u how to build a custom UI component for iOS with SwiftUI - TabSegmentControlView.

the problem

Need to create segment control with a nice selected part that looks like the next:

design



Using SwiftUI is much simpler than it looks at first look.

solution

As always - it’s better to decompose the task into small pieces and do it one by one.

We need a few things from this controller:

1). we want to receive some feedback about the selection 2). we want to pass into component a set of items to be shown as tabs 3). we want to configure the appearance of this component - like tintColor and other

Well, this dictates the init for our control.

For point 1 we can just use the @Binding value

@Binding private var selection: Int

If u wondering about all these propertyWrappers that are used in SwiftUI - check my post about them available here.

With point 2 - all a bit more complicated, but just a bit: let’s create a simple structure that can hold our information about the content on each tab:

public struct ElementData {
	let title: String
	let imageName: String
}

Now, let’s think about this component a bit - we are going to create the needed interface for it.

And last, but not least point - we need to send inside our control theme stuff - for now it’s just a color of background and selection:

private let tintColor: Color
private let selectionColor: Color

As result - we can receive this:

public struct TabSegmentControlView: View {
  public struct ElementData {
    let title: String
    let imageName: String
  }

  @Binding private var selection: Int
  private let items: [ElementData]
  private let tintColor: Color
  private let selectionColor: Color

  public init(
    items: [ElementData],
    tintColor: Color,
    pickerColor: Color,
    selection: Binding<Int>
  ) {
  // init props
  ...

Now, let’s prepare the next piece - view for tab. We have already defined this element data structure. What we need - is to show some text and title. We could create a separate View for this or just a function that creates a similar view inside our component. I decided to go with function - because I don’t think this view can be reused anywhere.

In general, this view will be just a vertical stack with elements:

element



VStack(spacing: 1) {
  Image(items[index].imageName)
  Text(items[index].title)
}

some spacing and padding can be added to adjust the appearance

We also need to think about the selected state. For this - we can just compare selection == index where selection is our @Binding and index - input to function that builds element:

func segmentItemView(for index: Int) -> some View {

// and on elements that must change tint:

.foregroundColor(selection == index ? tintColor : selectionColor)

One moment here - we need to handle somehow tap of the user - for this purpose, we can use tapGesture:

.onTapGesture { // action }

The tricky moment here is that tap is accepted by content shape - where the actual content is shown. To override and extend this area we can use an additional modifier:

extension View {
  func increaseTapArea() -> some View {
    self.contentShape(Rectangle())
  }
}
full code for element

  private func segmentItemView(for index: Int) -> some View {
    VStack {
      Spacer(minLength: 4)
      HStack {
        Spacer(minLength: 0)
        VStack(spacing: 1) {
          Image(items[index].imageName)
            .renderingMode(.template)
            .foregroundColor(selection == index ? tintColor : selectionColor)
            .padding(.horizontal, 16)
            .padding(.top, 8)
            .padding(.bottom, 3)
          Text(items[index].title)
            .foregroundColor(tintColor)
            .opacity(selection == index ? 1 : 0 )
        }
        Spacer(minLength: 4)
      }
      Spacer(minLength: 0)
    }
    .increaseTapArea()
    .onTapGesture { 
    	self.selection = index 
    }
  }


Now, we can put all the elements in the panel in one row and add some alignment:

VStack(alignment: .center, spacing: 0) {
  VStack(alignment: .leading, spacing: 0) {
    HStack {
      ForEach(0 ..< items.count, id: \.self) { index in
        segmentItemView(for: index)
          .frame(
            height: 87
          )
      }
    }
  }
}
.background(tintColor)

The result:

demo_v1



As u can see we have a few issues here - animation is not a good one. To resolve this we need just add the .animation viewModifier to an image.

With text all a bit more tricky - u cannot animate font color, instead, we can use a small workaround - put 2 Text objects one at another and animate its opacity:

  ZStack {
    Text(items[index].title)
      .foregroundColor(tintColor)
      .opacity(selection == index ? 1 : 0 )

    Text(items[index].title)
      .foregroundColor(selectionColor)
      .opacity(selection == index ? 0 : 1)
  }
  .animation(.default, value: selection)

Result - a better transition:

demo_v1



Now, we need to add a selection indicator. The easy way to do that - is to add background and animate its position on selection.

We can start with a simple rect:

.background(
  HStack {
    Rectangle()
      .fill(.white)
    Spacer(minLength: 0)
  }
)

The result:

simple_selector.png



Good, now we need to determine the width of the screen. To do so, we can use GeometryReader.

Read more about GeometryReader in my post here

But to be able to not use multiply GeometryReader we can use a trick with ZStack and color and store value into property:

    ZStack {
      GeometryReader { geometry in
        Color.clear
          .onAppear {
            segmentSize = geometry.size
          }
      }
      .frame(maxWidth: .infinity, maxHeight: 1)

// later use to calculate the size of the element

  private var selectedItemWidth: CGFloat {
    segmentSize.width / CGFloat(items.count)
  }

// and add it to the selector
	.frame(width: selectedItemWidth)

Result:

simple_selector.png



But when we want to interact - the selector is not moving:

simple_selector.png



To solve this - we need to calculate the offset for the selected element and animate its change:

// calculate current offset 
func selectedItemHorizontalOffset() -> CGFLoat {
  CGFloat(selection) * selectedItemWidth
}

// modify selector
.offset(x: selectedItemHorizontalOffset(), y: 0)
.animation(.linear(duration: 0.3), value: selection)

Result:

simple_selector.png



Great - the last part, is to define the selector to be in the same shape as it’s drawn in the design.

To achieve this, we can use the Shape protocol from SwiftUI. This is a protocol that accepts paths for some figures. After, we can use it as any View.

I won’t cover how to build the path, instead, if u interested - below is the full code for that.

custom shape

struct SegmentSelectionShape: Shape {
  func path(in rect: CGRect) -> Path {

    let targetRectSideOffset = rect.width * 0.45
    let newRect: CGRect = .init(
      origin: .init(
        x: rect.origin.x - targetRectSideOffset,
        y: rect.origin.y
      ),
      size: .init(
        width: rect.width + targetRectSideOffset * 2,
        height: rect.height)
    )

    var path = Path()
    path.move(to: .init(x: newRect.origin.x, y: newRect.maxY))
    path.addArc(
      center: .init(
        x: newRect.origin.x,
        y: newRect.maxY-targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(.pi/2),
      endAngle: .radians(0),
      clockwise: true
    )
    path.addLine(
      to: .init(
        x: 0,
        y: newRect.maxY-targetRectSideOffset*2
      )
    )

    path.addArc(
      center: .init(
        x: targetRectSideOffset,
        y: targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(.pi),
      endAngle: .radians(3.0 * .pi/2),
      clockwise: false
    )

    path.addLine(
      to: .init(
        x: newRect.maxX - targetRectSideOffset * 2,
        y: 0
      )
    )

    path.addArc(
      center: .init(
        x: newRect.maxX - targetRectSideOffset * 2,
        y: targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(3.0 * .pi/2),
      endAngle: .radians(2.0 * .pi),
      clockwise: false
    )

    path.addLine(
      to: .init(
        x: newRect.maxX - targetRectSideOffset,
        y: newRect.maxY-targetRectSideOffset
      )
    )

    path.addArc(
      center: .init(
        x: newRect.maxX,
        y: newRect.maxY-targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(.pi),
      endAngle: .radians(.pi / 2.0),
      clockwise: true
    )

    path.addLine(to: .init(x: newRect.origin.x, y: newRect.maxY))

    return path
  }
}


Now, when we have a shape defined - let’s replace Rectangle with our SegmentSelectionShape.

Result with adding some padding to the component:

selector_demo



Great - this is what we exactly want to achieve.

full code for component

public struct TabSegmentControlView: View {
  public struct ElementData {
    let title: String
    let imageName: String
  }

  @Binding private var selection: Int
  @State private var segmentSize: CGSize = .zero
  @State private var itemTitleSizes: [CGSize] = []

  private let items: [ElementData]
  private let tintColor: Color
  private let selectionColor: Color

  public init(
    items: [ElementData],
    tintColor: Color,
    pickerColor: Color,
    selection: Binding<Int>
  ) {
    self._selection = selection
    self.items = Array(items)
    self.tintColor = tintColor
    self.selectionColor = pickerColor
    self._itemTitleSizes = State(initialValue: [CGSize](repeating: .zero, count: items.count)
    )
  }

  public var body: some View {
    ZStack {
      GeometryReader { geometry in
        Color.clear
          .onAppear {
            segmentSize = geometry.size
          }
      }
      .frame(maxWidth: .infinity, maxHeight: 1)

      VStack(alignment: .center, spacing: 0) {
        VStack(alignment: .leading, spacing: 0) {
          HStack {
            ForEach(0 ..< items.count, id: \.self) { index in
              segmentItemView(for: index)
                .frame(height: 87)
            }
          }
          .background(
            HStack {
              SegmentSelectionShape()
                .fill(.white)
                .frame(width: selectedItemWidth)
                .offset(x: selectedItemHorizontalOffset(), y: 0)
                .animation(.linear(duration: 0.3), value: selection)
              Spacer(minLength: 0)
            }
          )
        }
      }
    }
    .background(tintColor)
  }

  private var selectedItemWidth: CGFloat {
    segmentSize.width / CGFloat(items.count)
  }

  private func segmentItemView(for index: Int) -> some View {
    VStack {
      Spacer(minLength: 4)
      HStack {
        Spacer(minLength: 0)
        VStack(spacing: 1) {
          Image(items[index].imageName)
            .renderingMode(.template)
            .foregroundColor(selection == index ? tintColor : selectionColor)
            .animation(.default, value: selection)
            .padding(.horizontal, 16)
            .padding(.top, 8)
            .padding(.bottom, 3)

          ZStack {
            Text(items[index].title)
              .foregroundColor(tintColor)
              .opacity(selection == index ? 1 : 0 )

            Text(items[index].title)
              .foregroundColor(selectionColor)
              .opacity(selection == index ? 0 : 1)
          }
          .animation(.default, value: selection)
        }
        Spacer(minLength: 4)
      }
      Spacer(minLength: 0)
    }
    .increaseTapArea()
    .onTapGesture { onItemTap(index: index) }
  }

  private func onItemTap(index: Int) {
    guard index < self.items.count else { return }
    self.selection = index
  }

  private func selectedItemHorizontalOffset() -> CGFloat {
    CGFloat(selection) * selectedItemWidth
  }
}

extension View {
  func increaseTapArea() -> some View {
    self.contentShape(Rectangle())
  }
}

struct SegmentSelectionShape: Shape {
  func path(in rect: CGRect) -> Path {

    let targetRectSideOffset = rect.width * 0.45
    let newRect: CGRect = .init(
      origin: .init(
        x: rect.origin.x - targetRectSideOffset,
        y: rect.origin.y
      ),
      size: .init(
        width: rect.width + targetRectSideOffset * 2,
        height: rect.height)
    )

    var path = Path()
    path.move(to: .init(x: newRect.origin.x, y: newRect.maxY))
    path.addArc(
      center: .init(
        x: newRect.origin.x,
        y: newRect.maxY-targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(.pi/2),
      endAngle: .radians(0),
      clockwise: true
    )
    path.addLine(
      to: .init(
        x: 0,
        y: newRect.maxY-targetRectSideOffset*2
      )
    )

    path.addArc(
      center: .init(
        x: targetRectSideOffset,
        y: targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(.pi),
      endAngle: .radians(3.0 * .pi/2),
      clockwise: false
    )

    path.addLine(
      to: .init(
        x: newRect.maxX - targetRectSideOffset * 2,
        y: 0
      )
    )

    path.addArc(
      center: .init(
        x: newRect.maxX - targetRectSideOffset * 2,
        y: targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(3.0 * .pi/2),
      endAngle: .radians(2.0 * .pi),
      clockwise: false
    )

    path.addLine(
      to: .init(
        x: newRect.maxX - targetRectSideOffset,
        y: newRect.maxY-targetRectSideOffset
      )
    )

    path.addArc(
      center: .init(
        x: newRect.maxX,
        y: newRect.maxY-targetRectSideOffset
      ),
      radius: targetRectSideOffset,
      startAngle: .radians(.pi),
      endAngle: .radians(.pi / 2.0),
      clockwise: true
    )

    path.addLine(to: .init(x: newRect.origin.x, y: newRect.maxY))

    return path
  }
}


conclusion

Divide and conqure works everywhere. Think about something complex as about the composition of simple.

resources