11

I want to achieve following using SwiftUI:

masked rectangle

This is what I have tried:

Text("test").mask(Rectangle().frame(width: 200, height: 100).foregroundColor(.white))

Also the other way around:

Rectangle().frame(width: 200, height: 100).foregroundColor(.white).mask(Text("test"))

Both of those samples gave me the inverse result of what I wanted. Meaning that only the text was showing in white with the rectangle being "masked away".

I also thought of the alternative where I simply combine Text and Rectangle in a ZStack. The rectangle having the foreground color and the text the background color. This would result in the same effect. However I don't want to do this as this seems like a hack to me. For instance if I want to add a gradient or an image to the background this method wouldn't work very well.

Is there a good way on how to do this in SwiftUI? I wouldn't mind if it is through a UIViewRepresentable.

ph1psG
  • 568
  • 5
  • 24

3 Answers3

4

Please refer to this anwser first, and then you'll understand the following code I made:

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        
        // text used in mask
        let text = Text("Text")
            .font(.system(size: 80, weight: .black, design: .rounded))
            .scaledToFit()                   // center text in view
        
        // container
        return ZStack {
            // background color
            Color.white.grayscale(0.3)
            // text card
            Gradient.diagonal(.yellow, .green)      // my custom extension 
                .inverseMask(text)                    // ⭐️ inverse mask
                // shadow for text
                .shadow(color: Color.black.opacity(0.7), radius: 3, x: 3, y: 3)
                .frame(width: 300, height: 200)
                // highlight & shadow
                .shadow(color: Color.white.opacity(0.9), radius: 18, x: -18, y: -18)
                .shadow(color: Color.black.opacity(0.3), radius: 14, x:  14, y:  14)
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

and the result is:

enter image description here

The key extension used in the above code is .inverseMask():

import SwiftUI

extension View {
    // view.inverseMask(_:)
    public func inverseMask<M: View>(_ mask: M) -> some View {
        // exchange foreground and background
        let inversed = mask
            .foregroundColor(.black)  // hide foreground
            .background(Color.white)  // let the background stand out
            .compositingGroup()       // ⭐️ composite all layers
            .luminanceToAlpha()       // ⭐️ turn luminance into alpha (opacity)
        return self.mask(inversed)
    }
}

----[Edited]-----

My custom extension for Gradient:

import SwiftUI

extension Gradient {
    
    // general linear gradient ---------------------------
    
    public static func linear(
        from start: UnitPoint, 
        to     end: UnitPoint, 
        colors    : [Color]       // use array
    ) -> LinearGradient 
    {
        LinearGradient(
            gradient  : Gradient(colors: colors), 
            startPoint: start, 
            endPoint  : end
        )
    }
    
    public static func linear(
        from start: UnitPoint, 
        to     end: UnitPoint, 
        colors    : Color...     // use variadic parameter
    ) -> LinearGradient 
    {
        linear(from: start, to: end, colors: colors)
    }
    
    // specialized linear gradients ------------------------
    
    // top to bottom
    public static func vertical(_ colors: Color...) -> LinearGradient {
        linear(from: .top, to: .bottom, colors: colors)
    }
    
    // leading to trailing
    public static func horizontal(_ colors: Color...) -> LinearGradient {
        linear(from: .leading, to: .trailing, colors: colors)
    }
    
    // top leading to bottom trailing
    public static func diagonal(_ colors: Color...) -> LinearGradient {
        linear(from: .topLeading, to: .bottomTrailing, colors: colors)
    }
    
    // top leading to bottom trailing
    public static func diagonal2(_ colors: Color...) -> LinearGradient {
        linear(from: .bottomLeading, to: .topTrailing, colors: colors)
    }
}
lochiwei
  • 1,240
  • 9
  • 16
2

Actually, even if it may seems like an hack to you, it's how SwiftUI works.

You can avoid this "hack" by creating a custom view

An example could be:

public struct BackgroundedText: View {

    var first_color = Color.green
    var second_color = Color.white
    var text_color = Color.green

    var size = CGSize(width: 200, height: 100)
    var xOffset: CGFloat = 50
    var yOffset: CGFloat = 50

    var text = "Hello world!"

    init(_ txt: String, _ txt_color: Color, _ fColor: Color, _ sColor: Color, _ size: CGSize, _ xOff: CGFloat, _ yOff: CGFloat) {
        self.text = txt
        self.text_color = txt_color
        self.first_color = fColor
        self.second_color = sColor
        self.size = size
        self.xOffset = xOff
        self.yOffset = yOff
    }


    public var body: some View {
        ZStack{
            Rectangle()
                .frame(width: self.size.width,
                       height: self.size.height)
                .foregroundColor(self.first_color)

            Rectangle()
            .frame(width: self.size.width - xOffset,
                   height: self.size.height - yOffset)
            .foregroundColor(self.second_color)

            Text(self.text)
                .foregroundColor(self.text_color)

        }
    }
}

So you can use the view in this way:

struct ContentView: View {
    var body: some View {
        BackgroundedText("Hello", .green, .green, .white, CGSize(width: 200, height: 100), 50, 50)
    }
}

If you want, you can make the rectangle resize based on text inside

Andrew21111
  • 868
  • 8
  • 17
  • 1
    The image above is actually just a screenshot of a small area. The green background could also be a gradient or image. I just want to cut out a piece of text of a rectangle. – ph1psG Oct 01 '19 at 12:36
  • What do you mean exactly? If you want the background be full screen, you can avoid setting the size for the background. Then, If you want it to be an image or something else, you can use a variable to choose a background color, image or gradient or, better, different init based on the background type – Andrew21111 Oct 01 '19 at 16:04
  • I think what the OP wants is a rectangle with "holes" in it, the shape of his text. The rectangle doesn't need to know what will show thru those holes; he wants a shape NOT IN THE SHAPE of the letters, but a rectangular shape, with HOLES in the shape of the letters. I'm looking for the same thing as well. – ConfusionTowers Nov 23 '19 at 03:50
  • 1
    Did anyone find a solution to this? I'm in the same boat as OP, I want to cut out the text from the view (there's a gradient in the background...) – Dominic Holmes Jan 26 '20 at 18:36
0

I had a similar requirement. Actually my requirement was that the text is a multiline text that scrolls up to reveal one line at a time ( timed with someone narrating the text. The background was an image.

I solved it the following way. A ZStack with the image first, then the Text layer with the multiline text positioned the way I want it, and then another layer of the image with a rectangular hole made where I want the text to appear through. The approach may meet your needs - you will need to position the hole, change colors etc. to meet your needs. The code shows a rectangle hole about three quarters of the way down the image.

struct TestView : View {
  var body: some View {
    GeometryReader { proxy in
      ZStack {
        Image("MyImage")
          .resizable()
          .scaledToFit()
        Text("Multiline\ntext\nfor\nscrolling")
          .font(.title).foregroundColor(.white)
          .position(x: proxy.size.width * 0.5, y: proxy.size.height * 0.75 )
        Image("MyImage")
          .resizable()
          .scaledToFit()
          .mask(self.makeMask(for: proxy.size))
      }
    }
  }

  func makeMask(for sz : CGSize) -> some View {
    return VStack(spacing: 0) {
      Rectangle().fill(Color.black)
        .frame(height: sz.height * 0.75 + 4)
      Rectangle().fill(Color.clear)
        .frame(height: 40)
      Rectangle().fill(Color.black)
        .frame(height: sz.height * 0.25 - 40)
    }
  }
}
shreyasm-dev
  • 2,711
  • 5
  • 16
  • 34