使用自动布局扩展为文本的 UITextView

我有一个视图,是完全使用自动布局编程布局。我有一个 UITextView 在视图的中间,上面和下面的项目。一切工作正常,但是我希望能够在添加文本时扩展 UITextView。随着它的膨胀,下面的所有东西都会被压下去。

我知道如何做到这一点的“弹簧和支柱”的方式,但有一个自动布局的方式做到这一点?我能想到的唯一方法是在每次需要增长时删除并重新添加约束。

112794 次浏览

包含 UITextView 的视图将通过 AutoLayout 使用 setBounds分配其大小。这就是我所做的。Superview 最初设置了所有其他的约束条件,最后我为 uitextView 的高度设置了一个特殊的约束条件,并将其保存在一个实例变量中。

_descriptionHeightConstraint = [NSLayoutConstraint constraintWithItem:_descriptionTextView
attribute:NSLayoutAttributeHeight
relatedBy:NSLayoutRelationEqual
toItem:nil
attribute:NSLayoutAttributeNotAnAttribute
multiplier:0.f
constant:100];


[self addConstraint:_descriptionHeightConstraint];

setBounds方法中,然后更改常量的值。

-(void) setBounds:(CGRect)bounds
{
[super setBounds:bounds];


_descriptionTextView.frame = bounds;
CGSize descriptionSize = _descriptionTextView.contentSize;


[_descriptionHeightConstraint setConstant:descriptionSize.height];


[self layoutIfNeeded];
}

UITextView 不提供 intrinsicContentSize,因此需要对其进行子类化并提供一个。若要使其自动增长,请使 layoutSubviews 中的 intrinsicContentSize 无效。如果使用默认 contentInset (我不推荐这样做)以外的任何其他方法,则可能需要调整 intrinsicContentSize 计算。

@interface AutoTextView : UITextView


@end

#import "AutoTextView.h"


@implementation AutoTextView


- (void) layoutSubviews
{
[super layoutSubviews];


if (!CGSizeEqualToSize(self.bounds.size, [self intrinsicContentSize])) {
[self invalidateIntrinsicContentSize];
}
}


- (CGSize)intrinsicContentSize
{
CGSize intrinsicContentSize = self.contentSize;


// iOS 7.0+
if ([[[UIDevice currentDevice] systemVersion] floatValue] >= 7.0f) {
intrinsicContentSize.width += (self.textContainerInset.left + self.textContainerInset.right ) / 2.0f;
intrinsicContentSize.height += (self.textContainerInset.top + self.textContainerInset.bottom) / 2.0f;
}


return intrinsicContentSize;
}


@end

你也可以不对 UITextView进行子类化,看看 我如何在 iOS7上根据内容调整 UITextView 的大小?

使用这个表达式的值:

[textView sizeThatFits:CGSizeMake(textView.frame.size.width, CGFLOAT_MAX)].height

更新 textView的高度 UILayoutConstraintconstant

摘要: 禁用文本视图的滚动,不要限制其高度。

要以编程方式完成此操作,请在 viewDidLoad中输入以下代码:

let textView = UITextView(frame: .zero, textContainer: nil)
textView.backgroundColor = .yellow // visual debugging
textView.isScrollEnabled = false   // causes expanding height
view.addSubview(textView)


// Auto Layout
textView.translatesAutoresizingMaskIntoConstraints = false
let safeArea = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
textView.topAnchor.constraint(equalTo: safeArea.topAnchor),
textView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor)
])

要在 Interface Builder 中实现这一点,请选择文本视图,取消选中属性检查器中的 scrollEnable,然后手动添加约束。

注意: 如果文本视图之上/之下还有其他视图,可以考虑使用 UIStackView来排列它们。

顺便说一下,我使用子类和重写内部内容大小构建了一个扩展的 UITextView。我在 UITextView 中发现了一个 bug,您可能希望在自己的实现中研究它。问题是:

如果您一次键入单个字母,扩展的文本视图将向下增长以适应不断增长的文本。但是如果你粘贴一堆文本到它里面,它不会向下生长,但是文本会向上滚动,顶部的文本看不到。

解决办法: 覆盖 setBound: 在您的子类中。由于某种未知的原因,粘贴导致 bound s.ogen.y 值为非 zee (在我看到的每个示例中为33)。所以我覆盖了 setBound: 来始终将 bound s.Orig.y 设置为零。解决了问题。

Obj C:


#import <UIKit/UIKit.h>


@interface ViewController : UIViewController


@property (nonatomic) UITextView *textView;
@end






#import "ViewController.h"


@interface ViewController ()


@end


@implementation ViewController


@synthesize textView;


- (void)viewDidLoad{
[super viewDidLoad];
[self.view setBackgroundColor:[UIColor grayColor]];
self.textView = [[UITextView alloc] initWithFrame:CGRectMake(30,10,250,20)];
self.textView.delegate = self;
[self.view addSubview:self.textView];
}


- (void)didReceiveMemoryWarning{
[super didReceiveMemoryWarning];
}


- (void)textViewDidChange:(UITextView *)txtView{
float height = txtView.contentSize.height;
[UITextView beginAnimations:nil context:nil];
[UITextView setAnimationDuration:0.5];


CGRect frame = txtView.frame;
frame.size.height = height + 10.0; //Give it some padding
txtView.frame = frame;
[UITextView commitAnimations];
}


@end

我发现,在仍然需要将 isScrollEnable 设置为 true 以允许合理的 UI 交互的情况下,这种情况并不少见。一个简单的例子是,当您想要允许自动展开文本视图,但仍然将其最大高度限制为 UITableView 中合理的高度时。

下面是我创建的 UITextView 的一个子类,它允许使用自动布局进行自动展开,但是仍然可以限制为最大高度,并且可以根据高度管理视图是否可滚动。默认情况下,如果以这种方式设置约束,视图将无限扩展。

import UIKit


class FlexibleTextView: UITextView {
// limit the height of expansion per intrinsicContentSize
var maxHeight: CGFloat = 0.0
private let placeholderTextView: UITextView = {
let tv = UITextView()


tv.translatesAutoresizingMaskIntoConstraints = false
tv.backgroundColor = .clear
tv.isScrollEnabled = false
tv.textColor = .disabledTextColor
tv.isUserInteractionEnabled = false
return tv
}()
var placeholder: String? {
get {
return placeholderTextView.text
}
set {
placeholderTextView.text = newValue
}
}


override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
isScrollEnabled = false
autoresizingMask = [.flexibleWidth, .flexibleHeight]
NotificationCenter.default.addObserver(self, selector: #selector(UITextInputDelegate.textDidChange(_:)), name: Notification.Name.UITextViewTextDidChange, object: self)
placeholderTextView.font = font
addSubview(placeholderTextView)


NSLayoutConstraint.activate([
placeholderTextView.leadingAnchor.constraint(equalTo: leadingAnchor),
placeholderTextView.trailingAnchor.constraint(equalTo: trailingAnchor),
placeholderTextView.topAnchor.constraint(equalTo: topAnchor),
placeholderTextView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}


required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}


override var text: String! {
didSet {
invalidateIntrinsicContentSize()
placeholderTextView.isHidden = !text.isEmpty
}
}


override var font: UIFont? {
didSet {
placeholderTextView.font = font
invalidateIntrinsicContentSize()
}
}


override var contentInset: UIEdgeInsets {
didSet {
placeholderTextView.contentInset = contentInset
}
}


override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize


if size.height == UIViewNoIntrinsicMetric {
// force layout
layoutManager.glyphRange(for: textContainer)
size.height = layoutManager.usedRect(for: textContainer).height + textContainerInset.top + textContainerInset.bottom
}


if maxHeight > 0.0 && size.height > maxHeight {
size.height = maxHeight


if !isScrollEnabled {
isScrollEnabled = true
}
} else if isScrollEnabled {
isScrollEnabled = false
}


return size
}


@objc private func textDidChange(_ note: Notification) {
// needed incase isScrollEnabled is set to true which stops automatically calling invalidateIntrinsicContentSize()
invalidateIntrinsicContentSize()
placeholderTextView.isHidden = !text.isEmpty
}
}

另外,还支持包含与 UILabel 类似的占位符文本。

你可以通过故事板来做,只要禁用“允许滚动”:)

StoryBoard

值得注意的一点是:

由于 UITextView 是 UIScrollView 的子类,因此它受到 UIViewController 的自动调整 ScrollViewInsets 属性的约束。

如果您正在设置布局,并且 TextView 是 UIViewController 层次结构中的第一个子视图,那么如果自动调整 ScrollViewInsets 为 true 有时会导致自动布局中的意外行为,那么它将修改其 contentInsets。

因此,如果您遇到自动布局和文本视图方面的问题,请尝试在视图控制器上设置 automaticallyAdjustsScrollViewInsets = false或在层次结构中向前移动 textView。

这里有一个解决方案给那些喜欢自动布局的人:

尺寸检查员:

  1. 将内容压缩阻力优先级垂直设置为1000。

  2. 降低约束高度的优先级,在约束中单击“编辑”。只要小于1000。

enter image description here

属性检查员:

  1. 取消选中“启用滚动”

维他命水的答案对我很有效。

如果文本视图的文本在编辑过程中上下跳动,在设置 [textView setScrollEnabled:NO];之后,设置 Size Inspector > Scroll View > Content Insets > Never

希望能有帮助。

将隐藏的 UILabel 置于文本视图下。标签行 = 0。将 UITextView 的约束设置为等于 UILabel (centerX,centerY,width,height)。即使保留 textView 的滚动行为也可以工作。

即插即用解决方案 -Xcode 9

UILabel类似,UITextView链路检测链路检测文本选择剪辑滚动都是自动布局。

自动处理

  • 安全区域
  • 内容插入
  • 线段填充
  • 文本容器插入
  • 约束
  • 堆栈视图
  • 属性字符串
  • 随便啦。

这些答案中有很多让我达到了90% ,但没有一个是万无一失的。

跳进这个 UITextView子类,你就没事了。


#pragma mark - Init


- (instancetype)initWithFrame:(CGRect)frame textContainer:(nullable NSTextContainer *)textContainer
{
self = [super initWithFrame:frame textContainer:textContainer];
if (self) {
[self commonInit];
}
return self;
}


- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
self = [super initWithCoder:aDecoder];
if (self) {
[self commonInit];
}
return self;
}


- (void)commonInit
{
// Try to use max width, like UILabel
[self setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
    

// Optional -- Enable / disable scroll & edit ability
self.editable = YES;
self.scrollEnabled = YES;
    

// Optional -- match padding of UILabel
self.textContainer.lineFragmentPadding = 0.0;
self.textContainerInset = UIEdgeInsetsZero;
    

// Optional -- for selecting text and links
self.selectable = YES;
self.dataDetectorTypes = UIDataDetectorTypeLink | UIDataDetectorTypePhoneNumber | UIDataDetectorTypeAddress;
}


#pragma mark - Layout


- (CGFloat)widthPadding
{
CGFloat extraWidth = self.textContainer.lineFragmentPadding * 2.0;
extraWidth +=  self.textContainerInset.left + self.textContainerInset.right;
if (@available(iOS 11.0, *)) {
extraWidth += self.adjustedContentInset.left + self.adjustedContentInset.right;
} else {
extraWidth += self.contentInset.left + self.contentInset.right;
}
return extraWidth;
}


- (CGFloat)heightPadding
{
CGFloat extraHeight = self.textContainerInset.top + self.textContainerInset.bottom;
if (@available(iOS 11.0, *)) {
extraHeight += self.adjustedContentInset.top + self.adjustedContentInset.bottom;
} else {
extraHeight += self.contentInset.top + self.contentInset.bottom;
}
return extraHeight;
}


- (void)layoutSubviews
{
[super layoutSubviews];
    

// Prevents flashing of frame change
if (CGSizeEqualToSize(self.bounds.size, self.intrinsicContentSize) == NO) {
[self invalidateIntrinsicContentSize];
}
    

// Fix offset error from insets & safe area
    

CGFloat textWidth = self.bounds.size.width - [self widthPadding];
CGFloat textHeight = self.bounds.size.height - [self heightPadding];
if (self.contentSize.width <= textWidth && self.contentSize.height <= textHeight) {
        

CGPoint offset = CGPointMake(-self.contentInset.left, -self.contentInset.top);
if (@available(iOS 11.0, *)) {
offset = CGPointMake(-self.adjustedContentInset.left, -self.adjustedContentInset.top);
}
if (CGPointEqualToPoint(self.contentOffset, offset) == NO) {
self.contentOffset = offset;
}
}
}


- (CGSize)intrinsicContentSize
{
if (self.attributedText.length == 0) {
return CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric);
}
    

CGRect rect = [self.attributedText boundingRectWithSize:CGSizeMake(self.bounds.size.width - [self widthPadding], CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin
context:nil];
    

return CGSizeMake(ceil(rect.size.width + [self widthPadding]),
ceil(rect.size.height + [self heightPadding]));
}

这里有一个快速的解决方案:

如果在文本视图中将 clipsToBounds 属性设置为 false,则可能会发生此问题。如果你只是简单地删除它,问题就会消失。

myTextView.clipsToBounds = false //delete this line

这是一个非常重要的评论

理解 维他命水的答案为什么起作用的关键有三点:

  1. 要知道 UITextView 是 UIScrollView 类的子类
  2. 了解 ScrollView 如何工作以及如何计算其 contentSize。有关详情,请参阅此 给你答案及其各种解决方案和评论。
  3. 了解 contentSize 是什么以及如何计算它。参见 给你给你。将 contentOffset设置为 很有可能也可能有所帮助,但是:

func setContentOffset(offset: CGPoint)
{
CGRect bounds = self.bounds
bounds.origin = offset
self.bounds = bounds
}

有关更多信息,请参见 对象滚动视图理解 scrollview


将这三者结合在一起,您很容易理解您需要允许 textView 的 内在内容大小按照 textView 的 AutoLayout 约束来驱动逻辑。就好像你的 textView 就像 UILabel 一样

要做到这一点,你需要禁用滚动,这基本上意味着 scrollView 的大小,contentSize 的大小,如果添加一个容器视图,那么容器视图的大小都是相同的。当他们是相同的,你没有滚动。你会有 0 contentOffset。有 0 contentOffSet意味着你没有向下滚动。连落后一分都没有!结果,textView 将被全部拉长。

0contentOffset意味着 scrollView 的边界和帧是相同的,这也没有什么价值。 如果你向下滚动5点,那么你的内容偏移量将是 5,而你的 scrollView.bounds.origin.y - scrollView.frame.origin.y将等于 5

我需要一个文本视图,将自动增长到一定的最大高度,然后成为滚动。迈克尔 · 林克的回答很有效,但我想看看是否能想出一些更简单的答案。这是我想到的:

斯威夫特5.3 Xcode 12

class AutoExpandingTextView: UITextView {


private var heightConstraint: NSLayoutConstraint!


var maxHeight: CGFloat = 100 {
didSet {
heightConstraint?.constant = maxHeight
}
}


private var observer: NSObjectProtocol?


override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
commonInit()
}


required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}


private func commonInit() {
heightConstraint = heightAnchor.constraint(equalToConstant: maxHeight)


observer = NotificationCenter.default.addObserver(forName: UITextView.textDidChangeNotification, object: nil, queue: .main) { [weak self] _ in
guard let self = self else { return }
self.heightConstraint.isActive = self.contentSize.height > self.maxHeight
self.isScrollEnabled = self.contentSize.height > self.maxHeight
self.invalidateIntrinsicContentSize()
}
}
}




我看到多个答案建议简单地关闭 scrollEnabled。这是最好的解决办法。我写这个答案是为了解释它为什么有效。

如果 scrollEnabled == NOUITextView实现 intrinsicContentSize属性:

- (CGSize)intrinsicContentSize {
if (self.scrollEnabled) {
return CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric);
} else {
// Calculate and return intrinsic content size based on current width.
}
}

这意味着您只需要确保文本视图的宽度受到足够的限制,然后您就可以使用内部内容高度(通过自动布局内容拥抱/压缩阻力优先级或在手动布局期间直接使用该值)。

不幸的是,这种行为没有记录在案。苹果可以很容易地为我们所有人省去一些麻烦... 不需要额外的高度限制,子类化等等。