2

I'm looking for a way to implement Liquid Glass effect in a Text, and I have issues.

If I want to do gradient effect in a Text, no problem, like below.

Text("Liquid Glass")
    .font(Font.system(size: 30, weight: .bold))
    .multilineTextAlignment(.center)
    .foregroundStyle(
        LinearGradient(
                colors: [.blue, .mint, .green],
                startPoint: .leading,
                endPoint: .trailing
        )
    )

But I cannot make sure that I can apply the .glassEffect with .mask or .foregroundStyle. The Glass type and Shape type argument looks like not compatible with the Text shape itself.

Any solution to do this effect on Text ?

0

1 Answer 1

7

You need to create a custom Shape that is the shape of the text. To do that, you need to get a Path from the text. I have found this code by malczak that produces a CGPath from an NSAttributedString. I have slightly modified it (used CTRunGetGlyphs, CTRunGetPositions, CTRunGetAdvances instead of their ...Ptr counterparts):

// ----------------------------------------------------------------------
// Library: TextPaths
// Source:  https://github.com/malczak/TextPaths
// Copyright (c) 2017 Matt
// Licensed under the MIT License: https://opensource.org/licenses/MIT
// ----------------------------------------------------------------------


typealias TextPathAttributes = [NSAttributedString.Key:Any]

/**
 Class represents single character representation - glyph
 */
public class TextPathGlyph {
    public typealias Index = String.UnicodeScalarView.Index
    
    /// Character index in source string unicode view
    public fileprivate(set) var index: Index
    
    /// Glyph path defined in glyph coordinates
    public fileprivate(set) var path: CGPath
    
    /// Glyph path position (top left corner) in line space
    public fileprivate(set) var position: CGPoint
    
    /// Glyph line advance
    public fileprivate(set) var advance = CGSize.zero
    
    /// Glyph origin offset (x component is a glyph origin offset, y component is an offset to baseline)
    public fileprivate(set) var originOffset = CGPoint.zero
    
    /// Glyph line run index
    fileprivate var lineRun: Int = 0
    
    /// Glyph line
    fileprivate weak var line: TextPathLine?
    
    /// Get glyph attributes as defined in source string
    public var attributes: [NSAttributedString.Key:Any]? {
        return line?.attributes(ForGlyph: self)
    }
    
    init(index: Index, path: CGPath, position: CGPoint){
        self.index = index
        self.path = path
        self.position = position
    }
}

/**
 Single text line representation
 */
public class TextPathLine {
    
    /// Line index
    public fileprivate(set) var index: Int
    
    /**
     Line typographic bounds based on line ascent / descent
     
     Rectangle is based on typographic line properties (ie. ascent, descent)
     */
    public fileprivate(set) var lineBounds = CGRect.zero
    
    /**
     Line path bounds based on text path
     
     Rectangle defined by _textBounds_ is smaller than _lineBounds_ and is based only o text bounds
     */
    public fileprivate(set) var textBounds = CGRect.zero
    
    /// Line leading
    public fileprivate(set) var leading = CGFloat(0.0)
    
    /// Line ascent
    public fileprivate(set) var ascent = CGFloat(0.0)
    
    /// Line descent
    public fileprivate(set) var descent = CGFloat(0.0)
    
    /**
     Line effective descent is calculated based on lineRuns typographic properties,
     in most cases is equal to _descent_.  In some rare cases this rect is smaller that _descent_ property.
     
     ## Example ##
     ````
     let str = NSMutableAttributedString(string: "First line".uppercased(), attributes: [
     NSFontAttributeName: UIFont.systemFont(ofSize: 22, weight: UIFontWeightUltraLight)
     ])
     str.append(NSAttributedString(string: "\nSecond line".uppercased(), attributes: [
     NSFontAttributeName: UIFont.systemFont(ofSize: 64, weight: UIFontWeightMedium)
     ]))
     ````
     In second line much larger font is used, it also applied to the 'line break' (\n)
     character. As a result typographic size of the first line is different that one used by TextKit to draw text
     in text components (UILabel, UITextView).
     In this case _effectiveDescent_ of the first line is smaller that _descent_ measured by CoreText, because it is calculated
     only based on visible characters (ie. not including '\n' metrics)
     */
    public fileprivate(set) var effectiveDescent = CGFloat(0.0)
    
    /// Line effective ascent (read more above)
    public fileprivate(set) var effectiveAscent = CGFloat(0.0)
    
    /// Line glyph to attributes mapping
    fileprivate var attributes: [TextPathAttributes]?
    
    /// Collection of all glyphs in line
    fileprivate var glyphs = [TextPathGlyph]()
    
    init(index: Int) {
        self.index = index
    }
    
    /**
     Enumerates over all glyphs in line
     - Parameter callback: Called for each glyph in line
     - Parameter line: Current text line
     - Parameter glyph: Current glyph
     */
    public func enumerateGlyphs(_ callback:(_ line: TextPathLine, _ glyph: TextPathGlyph) -> ()) {
        for glyph in glyphs {
            callback(self, glyph)
        }
    }
    
    /**
     Get attributes for a line glyph
     - Parameter glyph: Glyph in line
     - Returns: Glyph attributes
     */
    fileprivate func attributes(ForGlyph glyph: TextPathGlyph) -> TextPathAttributes? {
        if let attributes = attributes {
            return attributes[glyph.lineRun]
        }
        return nil
    }
}

/**
 Text frame representation. This can represent a simple text rectangle (eq. UITextView text content),
 as well as a complex frame defined by CGPath
 */
public class TextPathFrame {
    
    /// Frame shape path (eq. textfield bounds rect)
    public fileprivate(set) var path: CGPath
    
    /// Text frame lines
    public fileprivate(set) var lines = [TextPathLine]()
    
    init(path: CGPath) {
        self.path = path
    }
    
    /**
     Enumerates over all glyphs in text frame
     - Parameter callback: Called for each glyph in text frame
     - Parameter line: Current text line
     - Parameter glyph: Current glyph
     */
    public func enumerateGlyphs(_ callback: @escaping(_ line: TextPathLine, _ glyph: TextPathGlyph) -> ()) {
        for line in lines {
            line.enumerateGlyphs(callback)
        }
    }
}

/**
 Text path represents a CGPath representation of text frame, glyphs and typographic properties
 */
public class TextPath {
    
    /// Attributed text used to generate text path
    public fileprivate(set) var attributedString: NSAttributedString
    
    /// Text path composed for input text
    public fileprivate(set) var composedPath: CGPath?
    
    /// Composed text path bounding box
    public fileprivate(set) var composedBounds: CGRect
    
    /// text frames
    public fileprivate(set) var frames = [TextPathFrame]()
    
    init(text: NSAttributedString, path: CGPath? = nil) {
        self.attributedString = text
        self.composedPath = path
        self.composedBounds = path?.boundingBoxOfPath ?? CGRect.zero
    }
}

public extension NSAttributedString {
    
    /**
     Creates a text path and a collection of text lines and
     glyphs with additional typographic informations (ie. ascent, descent, bounds)
     
     - Parameter bounds: text bounding box
     - Parameter withAttributes: if _true_ glyph attributes are included in returned TextPath
     - Parameter withPath: if _true_ a composed text path is included
     - Returns: created text path or NULL if failed
     */
    func getTextPath(InBounds bounds:CGSize, withAttributes: Bool = false, withPath: Bool = true) -> TextPath? {
        let clearText = self.string
        if clearText.isEmpty {
            return nil
        }
        
        let fontAttributeKey = NSAttributedString.Key.font
        let defaultAttributes: TextPathAttributes = [
            fontAttributeKey: UIFont.systemFont(ofSize: UIFont.systemFontSize),
            NSAttributedString.Key.foregroundColor: UIColor.black
        ]
        
        var lineIndex = 0
        let unicodeScalars = clearText.unicodeScalars
        var unicodeIndex = unicodeScalars.startIndex
        
        let frameSetter = CTFramesetterCreateWithAttributedString(self)
        let textRange = CFRangeMake(0, self.length)
        let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(frameSetter, textRange, nil, bounds, nil)
        let framePath = UIBezierPath(rect: CGRect(origin: .zero, size: frameSize)).cgPath
        let frame = CTFramesetterCreateFrame(frameSetter, textRange, framePath, nil)
        
        let tpFrame = TextPathFrame(path: framePath)
        let frames = [tpFrame]
        
        // 0. fetch all lines / glyphs + text path
        let ignoredCharsSet = CharacterSet.whitespacesAndNewlines
        let path = CGMutablePath()
        
        if let lines = CTFrameGetLines(frame) as? [CTLine] {
            var linesShift = CGFloat(0)
            var origins = [CGPoint](repeating: CGPoint.zero, count: lines.count)
            CTFrameGetLineOrigins(frame, CFRangeMake(0, lines.count), &origins)
            var originItr = origins.makeIterator()
            
            for line in lines {
                let lineOrigin = originItr.next() ?? CGPoint.zero
                let tpLine = TextPathLine(index: lineIndex)
                
                tpLine.lineBounds = CTLineGetBoundsWithOptions(line, .excludeTypographicLeading)
                tpLine.textBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
                
                let _ = CTLineGetTypographicBounds(line, &tpLine.ascent, &tpLine.descent, &tpLine.leading)
                
                if let lineRuns = CTLineGetGlyphRuns(line) as? [CTRun] {
                    if withAttributes {
                        tpLine.attributes = [TextPathAttributes](repeating:defaultAttributes,
                                                                 count: lineRuns.count)
                    }
                    
                    var effectiveDescent = CGFloat(0)
                    var effectiveAscent = CGFloat(0)
                    
                    var lineRunIndex = 0
                    for lineRun in lineRuns {
                        let glyphsCount = CTRunGetGlyphCount(lineRun)
                        if glyphsCount == 0 {
                            continue
                        }
                        
                        let attributes = (CTRunGetAttributes(lineRun) as? TextPathAttributes) ?? defaultAttributes
                        let font = (attributes[fontAttributeKey] as? UIFont) ?? (defaultAttributes[fontAttributeKey] as! UIFont)
                        
                        if withAttributes {
                            tpLine.attributes![lineRunIndex] = attributes
                        }
                        
                        var rt_ascent = CGFloat(0.0)
                        var rt_descent = CGFloat(0.0)
                        var rt_leading = CGFloat(0.0)
                        let _ = CTRunGetTypographicBounds(lineRun, CFRangeMake(0, glyphsCount), &rt_ascent, &rt_descent, &rt_leading)
                        
                        let range = CFRangeMake(0, glyphsCount)
                        let glyphsBuffer = UnsafeMutableBufferPointer<CGGlyph>.allocate(capacity: glyphsCount)
                        let positionsBuffer = UnsafeMutableBufferPointer<CGPoint>.allocate(capacity: glyphsCount)
                        let advancesBuffer = UnsafeMutableBufferPointer<CGSize>.allocate(capacity: glyphsCount)
                        defer {
                            glyphsBuffer.deallocate()
                            positionsBuffer.deinitialize()
                            positionsBuffer.deallocate()
                            advancesBuffer.deinitialize()
                            advancesBuffer.deallocate()
                        }
                        let lineRunInfo = (glyphsBuffer.baseAddress, positionsBuffer.baseAddress, advancesBuffer.baseAddress)
                        switch( lineRunInfo ) {
                        case let (glyphsPtr?, positionsPtr?, advancesPtr?):
                            CTRunGetGlyphs(lineRun, range, glyphsPtr)
                            CTRunGetPositions(lineRun, range, positionsPtr)
                            CTRunGetAdvances(lineRun, range, advancesPtr)
                            var glyphPtr = glyphsPtr
                            var positionPtr = positionsPtr
                            var advancePtr = advancesPtr
                            
                            for _ in 0..<glyphsCount {
                                let glyphUnicodeIndex = unicodeIndex
                                unicodeIndex = unicodeScalars.index(after: unicodeIndex)
                                
                                if(!ignoredCharsSet.contains(unicodeScalars[glyphUnicodeIndex])) {
                                    effectiveAscent = max(effectiveAscent, abs(rt_ascent))
                                    effectiveDescent = max(effectiveDescent, abs(rt_descent))
                                    
                                    let glyph = glyphPtr.pointee
                                    let position = positionPtr.pointee
                                    
                                    var T = CGAffineTransform(scaleX: 1, y: 1)
                                    let ctFont = font as CTFont
                                    if let glyphPath = CTFontCreatePathForGlyph(ctFont, glyph, &T) {
                                        let pathBounds = glyphPath.boundingBoxOfPath
                                        var pathOffset = CGAffineTransform(translationX: -pathBounds.origin.x, y: -pathBounds.origin.y)
                                        let glyphPathRel = glyphPath.copy(using: &pathOffset) ?? glyphPath
                                        let originOffset = CGPoint(x: -pathBounds.origin.x, y: pathBounds.origin.y)
                                        let offset = CGPoint(x: lineOrigin.x + position.x + pathBounds.origin.x,
                                                             y: lineOrigin.y + position.y + pathBounds.origin.y)
                                        
                                        let tpGlyph = TextPathGlyph(index: glyphUnicodeIndex, path: glyphPathRel, position: offset)
                                        tpGlyph.lineRun = lineRunIndex
                                        tpGlyph.advance = advancePtr.pointee
                                        tpGlyph.originOffset = originOffset
                                        
                                        tpGlyph.line = tpLine
                                        tpLine.glyphs.append(tpGlyph)
                                    }
                                }
                                glyphPtr = glyphPtr.successor()
                                positionPtr = positionPtr.successor()
                                advancePtr = advancePtr.successor()
                            }
                            break;
                        default:
                            return nil
                        }
                        
                        lineRunIndex += 1
                    }
                    
                    if tpLine.glyphs.count != 0 {
                        tpLine.effectiveAscent = effectiveAscent
                        tpLine.effectiveDescent = effectiveDescent
                        for tpGlyph in tpLine.glyphs {
                            let position = tpGlyph.position
                            let offset = CGPoint(x: position.x, y: position.y + (tpLine.ascent - tpLine.effectiveAscent) + linesShift)
                            let T = CGAffineTransform(translationX: offset.x, y: offset.y)
                            path.addPath(tpGlyph.path, transform: T)
                            tpGlyph.position = offset
                        }
                        
                        tpFrame.lines.append(tpLine)
                        lineIndex += 1
                    }
                    
                    
                    linesShift += (tpLine.ascent + tpLine.descent) - (effectiveAscent + effectiveDescent)
                }
            }
        }
        
        var finalPath = path as CGPath
        var pathBounds = CGRect.zero
        var matrix = CGAffineTransform.identity
        
        // 1. move path to (0,0) (and glyphs)
        pathBounds = path.boundingBoxOfPath
        let pathOffset = pathBounds.origin
        matrix = CGAffineTransform(translationX: -pathOffset.x, y: -pathOffset.y)
        if let copyPath = path.copy(using: &matrix) {
            finalPath = copyPath
            
            for tpFrame in frames {
                tpFrame.enumerateGlyphs { _, glyph in
                    glyph.position = glyph.position.applying(matrix)
                }
            }
        }
        
        // 2. flip path (and glyphs)
        pathBounds = path.boundingBoxOfPath
        matrix = CGAffineTransform(scaleX: 1, y: -1)
        matrix = matrix.translatedBy(x: 0, y: -pathBounds.size.height)
        if let copyPath = path.copy(using: &matrix) {
            finalPath = copyPath
            
            for tpFrame in frames {
                tpFrame.enumerateGlyphs { _, glyph in
                    let glyphPath = glyph.path
                    let glyphBounds = glyphPath.boundingBoxOfPath
                    let glyphHeight = glyphBounds.size.height
                    var flipMatrix = CGAffineTransform(scaleX: 1, y: -1)
                    flipMatrix = flipMatrix.translatedBy(x: 0, y: -glyphHeight)
                    if let copyPath = glyphPath.copy(using: &flipMatrix) {
                        glyph.path = copyPath
                        let position = glyph.position.applying(matrix).applying(CGAffineTransform(translationX: 0, y: -glyphHeight))
                        glyph.position = position
                    }
                }
            }
        }
        
        let tp = TextPath(text: self, path: withPath ? finalPath : nil)
        tp.composedBounds = CGRect(origin: pathOffset, size: finalPath.boundingBoxOfPath.size)
        tp.frames.append(contentsOf: frames)
        return tp
    }
}

// ----------------------------------------------------------------------
// MIT License
//
// Permission is hereby granted, free of charge, to any person obtaining 
// a copy of this software and associated documentation files (the "Software"), 
// to deal in the Software without restriction, including without limitation 
// the rights to use, copy, modify, merge, publish, distribute, sublicense, 
// and/or sell copies of the Software, and to permit persons to whom the 
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included 
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// ----------------------------------------------------------------------

This code creates a whole bunch of class instances (TextPathGlyph, TextPathLine, TextPathFrame etc) with information which you don't really need. If this becomes a performance problem, you could change these to structs, or refactor to eliminate them completely.

If you don't need support for multi-line text, something like this should work for "simple" cases.

Then, the Shape can be implemented like this:

struct TextShape: Shape {
    let text: AttributedString
    func path(in rect: CGRect) -> Path {
        let nsAttrStr = NSAttributedString(text)
        let textPath = nsAttrStr.getTextPath(InBounds: rect.size)!
        let bounds = textPath.composedBounds
        let path = textPath.composedPath!
        return Path(path)
            // put the text at the center of 'rect'
            .applying(.init(
                translationX: rect.midX - bounds.width / 2,
                y: rect.midY - bounds.height / 2)
            )
    }
}

If the text is constant, you can call getTextPath just once and store the Path in a global let somewhere.

Finally, to create the glass effect, put glassEffect on anything transparent, and pass in TextShape as the shape.

Rectangle()
    .fill(.clear)
    .glassEffect(
        .clear,
        in: TextShape(
            text: AttributedString(
                "Hello World",
                attributes: .init().font(.systemFont(ofSize: 50, weight: .heavy))
            )
        )
    )

Here is an example overlaying the above on top of an image.

enter image description here

Sign up to request clarification or add additional context in comments.

5 Comments

Looks like there is expensive computations. It may be better to use asynchronous background methods for getTextPath method. Also, it's not working, I don't see the text with Liquid Glass effect.
When you say I don't see the text with Liquid Glass effect, did you try putting some kind of varied background behind the text? The glass effect only works when there is content behind it, in other words, when the light coming from the background is bent by the glass. A solid color doesn't really work either, the background needs to be more varied (like the image in the answer).
@Kous92 you can measure the performance on your own. It depends on how often path(in:) is called. I don’t think it’d be too bad if it is just called once with a short piece of text. I don’t imagine you’d be showing long paragraphs with this, would you? This works on beta 5 for me. Are you using a newer beta?
Yes, I use Beta 7.
@Kous92 Okay, let's wait until the official release then and see if it works then. Interestingly, I see the message "IOSurfaceClientSetSurfaceNotify failed e00002c7" in the logs in beta 5. Do you see this in beta 7 as well? This message appears as long as the shape is not one of those "standard" shapes. Try drawing a rectangle with move(to:) and addLine(to:) - even the shape is visually the same as the standard .rect, this message appears.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.