swiftuisplitzstack

How can I combine two half view with zstack in SwiftUI?


I have a map pin view that represents an action.

Action 1

Action 2

This is the code

VStack {
                                ActionView(action: place.actions.first!)
                                    .frame(width: 35, height: 35)
                                Text(self.displayPlaceName ? place.name : "")
                            }.overlay(
                                Image(systemName: "arrowtriangle.left.fill")
                                    .rotationEffect(Angle(degrees: 270))
                                    .foregroundColor(place.actions.first!.color())
                                    .offset(y: 10)
                            )

The pin can represent only one action.

If there are two actions I would like to see half of both pin. something like that.

Expected result

Thanks, Nicolas


Solution

  • One way to hide half of a view is by applying a gradient mask. If we put two stops in the gradient at the same location with different colors, we get an instant change (a step) in the gradient rather than a smooth transition. Some code:

    struct ContentView: View {
      var body: some View {
        Callout(borderColor: .brown) {
          Text("🍲")
        }
        .mask {
          Rectangle()
            .fill(.linearGradient(
              stops: [
                // The specific color doesn't matter here, only its alpha,
                // which is 1 for .red and 0 for .clear.
                .init(color: .red, location: 0),
                .init(color: .red, location: 0.5),
                .init(color: .clear, location: 0.5),
                .init(color: .clear, location: 1.0),
              ],
              startPoint: .leading,
              endPoint: .trailing
            ))
        }
      }
    }
    

    Result:

    the left half of a callout containing a soup bowl emoji

    We can swap the gradient colors to show the other half of the masked content. So, we can use a ZStack containing two callouts, with opposite halves masked:

    struct ContentView: View {
      var body: some View {
        ZStack {
          Callout(borderColor: .brown) {
            Text("🍲")
          }
          .mask {
            Rectangle()
              .fill(.linearGradient(
                stops: [
                  // The specific color doesn't matter here, only its alpha,
                  // which is 1 for .red and 0 for .clear.
                  .init(color: .red, location: 0),
                  .init(color: .red, location: 0.5),
                  .init(color: .clear, location: 0.5),
                  .init(color: .clear, location: 1.0),
                ],
                startPoint: .leading,
                endPoint: .trailing
              ))
          }
    
          Callout(borderColor: .green) {
            Text("📸")
          }
          .mask {
            Rectangle()
              .fill(.linearGradient(
                stops: [
                  .init(color: .clear, location: 0),
                  .init(color: .clear, location: 0.5),
                  .init(color: .red, location: 0.5),
                  .init(color: .red, location: 1.0),
                ],
                startPoint: .leading,
                endPoint: .trailing
              ))
          }
        }
      }
    }
    

    Result:

    The left half shows a brown-framed callout of a soup bowl emoji. The right half shows a green-framed callout of a camera emoji.

    The result is a sharp cut between the two callouts. You can fade one into the other by replacing the step in the gradients with an interpolation:

    struct ContentView: View {
      var body: some View {
        ZStack {
          Callout(borderColor: .brown) {
            Text("🍲")
          }
          .mask {
            Rectangle()
              .fill(.linearGradient(
                stops: [
                  .init(color: .red, location: 0),
                  .init(color: .red, location: 0.4),   // <<< CHANGED
                  .init(color: .clear, location: 0.6), // <<< CHANGED
                  .init(color: .clear, location: 1.0),
                ],
                startPoint: .leading,
                endPoint: .trailing
              ))
          }
    
          Callout(borderColor: .green) {
            Text("📸")
          }
          .mask {
            Rectangle()
              .fill(.linearGradient(
                stops: [
                  .init(color: .clear, location: 0),
                  .init(color: .clear, location: 0.4), // <<< CHANGED
                  .init(color: .red, location: 0.6),   // <<< CHANGED
                  .init(color: .red, location: 1.0),
                ],
                startPoint: .leading,
                endPoint: .trailing
              ))
          }
        }
      }
    }
    

    Result:

    same as prior result except the left callout fades into the right callout over several pixel columns

    Here's the source for my Callout view in case you want to play with it:

    struct CalloutBorderShape: Shape {
      let arrowHeight: CGFloat
    
      func path(in rect: CGRect) -> Path {
        let rect = rect.divided(atDistance: arrowHeight, from: .maxYEdge).remainder
        let lineWidth: CGFloat = 3
        var path = Path()
        path.addRoundedRect(
          in: rect.insetBy(dx: 0.5 * lineWidth, dy: 0.5 * lineWidth),
          cornerSize: .init(width: 2 * lineWidth, height: 2 * lineWidth)
        )
        path = path.strokedPath(.init(lineWidth: lineWidth))
        path.move(to: .init(x: rect.midX, y: rect.maxY + arrowHeight))
        path.addLine(to: .init(x: rect.midX - arrowHeight, y: rect.maxY))
        path.addLine(to: .init(x: rect.midX + arrowHeight, y: rect.maxY))
        path.closeSubpath()
        return path
      }
    }
    
    struct Callout<Content: View>: View {
      let borderColor: Color
    
      @ViewBuilder
      let content: Content
    
      var body: some View {
        content
          .padding(EdgeInsets(
            top: paddingSize,
            leading: paddingSize,
            bottom: paddingSize + arrowHeight,
            trailing: paddingSize
          ))
          .overlay {
            CalloutBorderShape(arrowHeight: arrowHeight)
              .fill(borderColor)
          }
      }
    
      private let paddingSize: CGFloat = 10
      private let arrowHeight: CGFloat = 6
    }