减少多行UILabel中最后一行的宽度

时间:2013-02-27 13:36:58

标签: ios objective-c cocoa-touch uilabel

我正在实现一个“阅读更多”功能,就像Apple的AppStore中那样。但是,我使用的是多行UILabel。看看Apple的AppStore,它们如何减少最后一条可见线的宽度以适应“更多”文本并仍然截断尾部(见图)?

iBooks example image from AppStore

6 个答案:

答案 0 :(得分:13)

这似乎有效,至少我已经完成了有限的测试。有两种公共方法。如果您有多个标签都具有相同的行数,则可以使用较短的标签 - 只需更改顶部的kNumberOfLines即可匹配您想要的标签。如果需要传递不同标签的行数,请使用较长的方法。请务必将您在IB中制作的标签类更改为RDLabel。使用这些方法而不是setText:。如果需要,这些方法会将标签的高度扩展为kNumberOfLines,如果仍然被截断,则会将其展开以适合触摸的整个字符串。目前,您可以触摸标签中的任何位置。改变它不应该太难,所以只能触及...... Mer会引起扩张。

#import "RDLabel.h"
#define kNumberOfLines 2
#define ellipsis @"...Mer ▾ "

@implementation RDLabel {
    NSString *string;
}

#pragma Public Methods

- (void)setTruncatingText:(NSString *) txt {
    [self setTruncatingText:txt forNumberOfLines:kNumberOfLines];
}

- (void)setTruncatingText:(NSString *) txt forNumberOfLines:(int) lines{
    string = txt;
    self.numberOfLines = 0;
    NSMutableString *truncatedString = [txt mutableCopy];
    if ([self numberOfLinesNeeded:truncatedString] > lines) {
        [truncatedString appendString:ellipsis];
        NSRange range = NSMakeRange(truncatedString.length - (ellipsis.length + 1), 1);
        while ([self numberOfLinesNeeded:truncatedString] > lines) {
            [truncatedString deleteCharactersInRange:range];
            range.location--;
        }
        [truncatedString deleteCharactersInRange:range];  //need to delete one more to make it fit
        CGRect labelFrame = self.frame;
        labelFrame.size.height = [@"A" sizeWithFont:self.font].height * lines;
        self.frame = labelFrame;
        self.text = truncatedString;
        self.userInteractionEnabled = YES;
        UITapGestureRecognizer *tapper = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(expand:)];
        [self addGestureRecognizer:tapper];
    }else{
        CGRect labelFrame = self.frame;
        labelFrame.size.height = [@"A" sizeWithFont:self.font].height * lines;
        self.frame = labelFrame;
        self.text = txt;
    }
}

#pragma Private Methods

-(int)numberOfLinesNeeded:(NSString *) s {
    float oneLineHeight = [@"A" sizeWithFont:self.font].height;
    float totalHeight = [s sizeWithFont:self.font constrainedToSize:CGSizeMake(self.bounds.size.width, CGFLOAT_MAX) lineBreakMode:NSLineBreakByWordWrapping].height;
    return nearbyint(totalHeight/oneLineHeight);
}

-(void)expand:(UITapGestureRecognizer *) tapper {
    int linesNeeded = [self numberOfLinesNeeded:string];
    CGRect labelFrame = self.frame;
    labelFrame.size.height = [@"A" sizeWithFont:self.font].height * linesNeeded;
    self.frame = labelFrame;
    self.text = string;
}

答案 1 :(得分:5)

由于这篇文章是从2013年开始的,我想让我的Swift实现@rdelmar非常好的解决方案。

考虑到我们正在使用UILabel的SubClass:

private let kNumberOfLines = 2
private let ellipsis = " MORE"

private var originalString: String! // Store the original text in the init

private func getTruncatingText() -> String {
    var truncatedString = originalString.mutableCopy() as! String

    if numberOfLinesNeeded(truncatedString) > kNumberOfLines {
        truncatedString += ellipsis

        var range = Range<String.Index>(
            start: truncatedString.endIndex.advancedBy(-(ellipsis.characters.count + 1)),
            end: truncatedString.endIndex.advancedBy(-ellipsis.characters.count)
        )

        while numberOfLinesNeeded(truncatedString) > kNumberOfLines {
            truncatedString.removeRange(range)

            range.startIndex = range.startIndex.advancedBy(-1)
            range.endIndex = range.endIndex.advancedBy(-1)
        }
    }

    return truncatedString
}

private func getHeightForString(str: String) -> CGFloat {
    return str.boundingRectWithSize(
        CGSizeMake(self.bounds.size.width, CGFloat.max),
        options: [.UsesLineFragmentOrigin, .UsesFontLeading],
        attributes: [NSFontAttributeName: font],
        context: nil).height
}

private func numberOfLinesNeeded(s: String) -> Int {
    let oneLineHeight = "A".sizeWithAttributes([NSFontAttributeName: font]).height
    let totalHeight = getHeightForString(s)
    return Int(totalHeight / oneLineHeight)
}

func expend() {
    var labelFrame = self.frame
    labelFrame.size.height = getHeightForString(originalString)
    self.frame = labelFrame
    self.text = originalString
}

func collapse() {
    let truncatedText = getTruncatingText()
    var labelFrame = self.frame
    labelFrame.size.height = getHeightForString(truncatedText)
    self.frame = labelFrame
    self.text = truncatedText
}

与旧解决方案不同,这对任何类型的文本属性(如NSParagraphStyleAttributeName)都有效。

请随意评论和评论。再次感谢@rdelmar。

答案 2 :(得分:4)

有多种方法可以做到这一点,最优雅的是只使用CoreText,因为您可以完全控制如何显示文本。

这是一个混合选项,我们使用CoreText重新创建标签,确定它的结束位置,然后在正确的位置剪切标签文本字符串。

NSMutableAttributedString *atrStr = [[NSAttributedString alloc] initWithString:label.text];
NSNumber *kern = [NSNumber numberWithFloat:0];
NSRange full = NSMakeRange(0, [atrStr string].length);
[atrStr addAttribute:(id)kCTKernAttributeName value:kern range:full];

CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)atrStr);  

CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, label.frame);
CTFrameRef frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);

CFArrayRef lines = CTFrameGetLines(frame);
CTLineRef line = (CTLineRef)CFArrayGetValueAtIndex(lines, label.numberOfLines-1);
CFRange r = CTLineGetStringRange(line);

这将为您提供标签文本最后一行的范围。从那里开始,将它缩小并将省略号放在你想要的位置是微不足道的。

第一部分创建一个属性字符串,其中包含复制UILabel行为所需的属性(可能不是100%,但应该足够接近)。 然后我们创建一个框架集和框架,并获取框架的所有线条,从中我们提取标签的最后一个预期线条的范围。

这显然是某种黑客行为,正如我所说,如果你想要完全控制文本的外观,你最好使用该标签的纯CoreText实现。

答案 3 :(得分:1)

ResponsiveLabel是UILabel的子类,允许添加响应触摸的自定义截断标记。

答案 4 :(得分:1)

我刚刚在Swift 4中编写了一个UILabel扩展,使用二进制搜索来加速子串计算

它最初是基于@ paul-slm的解决方案,但却有很大分歧

extension UILabel {

func getTruncatingText(originalString: String, newEllipsis: String, maxLines: Int?) -> String {

    let maxLines = maxLines ?? self.numberOfLines

    guard maxLines > 0 else {
        return originalString
    }

    guard self.numberOfLinesNeeded(forString: originalString) > maxLines else {
        return originalString
    }

    var truncatedString = originalString

    var low = originalString.startIndex
    var high = originalString.endIndex
    // binary search substring
    while low != high {
        let mid = originalString.index(low, offsetBy: originalString.distance(from: low, to: high)/2)
        truncatedString = String(originalString[..<mid])
        if self.numberOfLinesNeeded(forString: truncatedString + newEllipsis) <= maxLines {
            low = originalString.index(after: mid)
        } else {
            high = mid
        }
    }

    // substring further to try and truncate at the end of a word
    var tempString = truncatedString
    var prevLastChar = "a"
    for _ in 0..<15 {
        if let lastChar = tempString.last {
            if (prevLastChar == " " && String(lastChar) != "") || prevLastChar == "." {
                truncatedString = tempString
                break
            }
            else {
                prevLastChar = String(lastChar)
                tempString = String(tempString.dropLast())
            }
        }
        else {
            break
        }
    }

    return truncatedString + newEllipsis
}

private func numberOfLinesNeeded(forString string: String) -> Int {
    let oneLineHeight = "A".size(withAttributes: [NSAttributedStringKey.font: font]).height
    let totalHeight = self.getHeight(forString: string)
    let needed = Int(totalHeight / oneLineHeight)
    return needed
}

private func getHeight(forString string: String) -> CGFloat {
    return string.boundingRect(
        with: CGSize(width: self.bounds.size.width, height: CGFloat.greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        attributes: [NSAttributedStringKey.font: font],
        context: nil).height
}
}

答案 5 :(得分:1)

我最终使用的是

@ paul-slm的answer,但是我发现这是一个非常耗时的过程,要一个一个地剥离可能长的字符串的最后一个字符,直到标签符合要求为止。行数。取而代之的是,从原始字符串的开头到空白字符串,一次一次复制一个字符,直到达到所需的行数才有意义。您还应该考虑不是一次跨一个字符,而是一次跨多个字符,以便更快地到达“最佳位置”。我将func getTruncatingText() -> String替换为以下内容:

private func getTruncatingText() -> String? {
    guard let originalString = originalString else { return nil }

    if numberOfLinesNeeded(originalString) > collapsedNumberOfLines {
        var truncatedString = ""
        var toyString = originalString
        while numberOfLinesNeeded(truncatedString + ellipsis) != (collapsedNumberOfLines + 1) {
            let toAdd = toyString.startIndex..<toyString.index(toyString.startIndex, offsetBy: 5)
            let toAddString = toyString[toAdd]
            toyString.removeSubrange(toAdd)
            truncatedString.append(String(toAddString))
        }

        while numberOfLinesNeeded(truncatedString + ellipsis) > collapsedNumberOfLines {
            truncatedString.removeSubrange(truncatedString.index(truncatedString.endIndex, offsetBy: -1)..<truncatedString.endIndex)
        }

        truncatedString += ellipsis
        return truncatedString
    } else {
        return originalString
    }
}