diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 724e96f3b..cff448967 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -425,6 +425,7 @@ D2AEACDC16C426DA00C364C0 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */; }; FC3BD9881A30A790005B96BB /* Social.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC3BD9871A30A790005B96BB /* Social.framework */; }; FCB11D8C1A129A76002F93FB /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCB11D8B1A129A76002F93FB /* CoreMedia.framework */; }; + FD0150582CA27DF3005B08A1 /* ScrollableLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0150572CA27DEE005B08A1 /* ScrollableLabelView.swift */; }; FD0606BF2BC8C10200C3816E /* _005_AddJobUniqueHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606BE2BC8C10200C3816E /* _005_AddJobUniqueHash.swift */; }; FD0606C32BCE13ED00C3816E /* MessageRequestFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */; }; FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; }; @@ -1641,6 +1642,7 @@ E1A0AD8B16E13FDD0071E604 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; FC3BD9871A30A790005B96BB /* Social.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Social.framework; path = System/Library/Frameworks/Social.framework; sourceTree = SDKROOT; }; FCB11D8B1A129A76002F93FB /* CoreMedia.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreMedia.framework; path = System/Library/Frameworks/CoreMedia.framework; sourceTree = SDKROOT; }; + FD0150572CA27DEE005B08A1 /* ScrollableLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableLabelView.swift; sourceTree = ""; }; FD0606BE2BC8C10200C3816E /* _005_AddJobUniqueHash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddJobUniqueHash.swift; sourceTree = ""; }; FD0606C22BCE13ED00C3816E /* MessageRequestFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestFooterView.swift; sourceTree = ""; }; FD078E4727E02561000769AF /* CommonMockedExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonMockedExtensions.swift; sourceTree = ""; }; @@ -3016,6 +3018,7 @@ FD52090628B49738006098F6 /* ConfirmationModal.swift */, C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */, C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */, + FD0150572CA27DEE005B08A1 /* ScrollableLabelView.swift */, FD71165A28E6DDBC00B47552 /* StyledNavigationController.swift */, FD0B77AF29B69A65009169BA /* TopBannerController.swift */, ); @@ -5558,6 +5561,7 @@ 942256992C23F8DD00C0FDBF /* Toast.swift in Sources */, C331FF972558FA6B00070591 /* Fonts.swift in Sources */, FD71165828E436E800B47552 /* Modal.swift in Sources */, + FD0150582CA27DF3005B08A1 /* ScrollableLabelView.swift in Sources */, FD37E9D328A1FCDB003AE748 /* Theme+OceanDark.swift in Sources */, 942256972C23F8DD00C0FDBF /* SessionSearchBar.swift in Sources */, FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */, diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index e25477a56..6f0f3984f 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -47,17 +47,8 @@ public class ConfirmationModal: Modal, UITextFieldDelegate { return result }() - private lazy var explanationLabelContainer: UIScrollView = { - let result: UIScrollView = UIScrollView() - result.isHidden = true - - return result - }() - - private lazy var explanationLabelContainerHeightConstraint = explanationLabelContainer.set(.height, to: 0) - - private lazy var explanationLabel: UILabel = { - let result: UILabel = UILabel() + private lazy var explanationLabel: ScrollableLabelView = { + let result: ScrollableLabelView = ScrollableLabelView() result.font = .systemFont(ofSize: Values.smallFontSize) result.themeTextColor = .alert_text result.textAlignment = .center @@ -115,7 +106,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate { }() private lazy var contentStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabelContainer, textFieldContainer, imageViewContainer ]) + let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, textFieldContainer, imageViewContainer ]) result.axis = .vertical result.spacing = Values.smallSpacing result.isLayoutMarginsRelativeArrangement = true @@ -186,10 +177,6 @@ public class ConfirmationModal: Modal, UITextFieldDelegate { contentView.addSubview(mainStackView) contentView.addSubview(closeButton) - explanationLabelContainer.addSubview(explanationLabel) - explanationLabel.pin(to: explanationLabelContainer) - explanationLabel.set(.width, to: .width, of: explanationLabelContainer) - textFieldContainer.addSubview(textField) textField.pin(to: textFieldContainer, withInset: 12) @@ -203,18 +190,6 @@ public class ConfirmationModal: Modal, UITextFieldDelegate { closeButton.pin(.right, to: .right, of: contentView, withInset: -8) } - private func layoutExplanationLabel(_ canScroll: Bool = true) { - let labelWidth = view.frame.width - 4 * Values.veryLargeSpacing - let maxLabelSize = CGSize(width: labelWidth, height: CGFloat.greatestFiniteMagnitude) - let expectedLabelSize = explanationLabel.sizeThatFits(maxLabelSize) - let lineHeight = explanationLabel.font.lineHeight - if canScroll { - explanationLabelContainerHeightConstraint.constant = min(expectedLabelSize.height, lineHeight * 5) - } else { - explanationLabelContainerHeightConstraint.constant = expectedLabelSize.height - } - } - // MARK: - Content public func updateContent(with info: Info) { @@ -245,20 +220,19 @@ public class ConfirmationModal: Modal, UITextFieldDelegate { case .text(let text, let canScroll): mainStackView.spacing = Values.smallSpacing explanationLabel.text = text - explanationLabelContainer.isHidden = false - self.layoutExplanationLabel(canScroll) + explanationLabel.canScroll = canScroll + explanationLabel.isHidden = false case .attributedText(let attributedText, let canScroll): mainStackView.spacing = Values.smallSpacing explanationLabel.attributedText = attributedText - explanationLabelContainer.isHidden = false - self.layoutExplanationLabel(canScroll) + explanationLabel.canScroll = canScroll + explanationLabel.isHidden = false case .input(let explanation, let placeholder, let value, let clearButton, let onTextChanged): explanationLabel.attributedText = explanation - explanationLabelContainer.isHidden = (explanation == nil) - let canScroll: Bool = false - self.layoutExplanationLabel(canScroll) + explanationLabel.canScroll = false + explanationLabel.isHidden = (explanation == nil) textField.placeholder = placeholder textField.text = (value ?? "") textField.clearButtonMode = (clearButton ? .always : .never) diff --git a/SessionUIKit/Components/ScrollableLabelView.swift b/SessionUIKit/Components/ScrollableLabelView.swift new file mode 100644 index 000000000..d10d380a5 --- /dev/null +++ b/SessionUIKit/Components/ScrollableLabelView.swift @@ -0,0 +1,155 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import UIKit + +class ScrollableLabelView: UIView { + private var oldSize: CGSize = .zero + private var layoutLoopCounter: Int = 0 + + var canScroll: Bool = false { + didSet { + guard canScroll != oldValue else { return } + + updateContentSizeIfNeeded() + } + } + + var font: UIFont { + get { label.font } + set { label.font = newValue } + } + + var text: String? { + get { label.text } + set { + guard label.text != newValue else { return } + + label.text = newValue + updateContentSizeIfNeeded() + } + } + + var attributedText: NSAttributedString? { + get { label.attributedText } + set { + guard label.attributedText != newValue else { return } + + label.attributedText = newValue + updateContentSizeIfNeeded() + } + } + + var themeTextColor: ThemeValue? { + get { label.themeTextColor } + set { label.themeTextColor = newValue } + } + + var textAlignment: NSTextAlignment { + get { label.textAlignment } + set { label.textAlignment = newValue } + } + + var lineBreakMode: NSLineBreakMode { + get { label.lineBreakMode } + set { label.lineBreakMode = newValue } + } + + var numberOfLines: Int { + get { label.numberOfLines } + set { label.numberOfLines = newValue } + } + + var maxNumberOfLinesWhenScrolling: Int = 5 { + didSet { + guard maxNumberOfLinesWhenScrolling != oldValue else { return } + + updateContentSizeIfNeeded() + } + } + + // MARK: - Initialization + + init() { + super.init(frame: .zero) + + setupViews() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UI Components + + private lazy var labelHeightAnchor: NSLayoutConstraint = label.set(.height, to: .height, of: scrollView).setting(isActive: false) + private lazy var scrollViewHeightAnchor: NSLayoutConstraint = scrollView.set(.height, to: 0).setting(isActive: false) + + private let scrollView: UIScrollView = UIScrollView() + private let label: UILabel = UILabel() + + // MARK: - Layout + + private func setupViews() { + addSubview(scrollView) + + scrollView.addSubview(label) + } + + private func setupConstraints() { + scrollView.pin(to: self) + + label.setContentHugging(.vertical, to: .required) + label.pin(to: scrollView) + label.set(.width, to: .width, of: scrollView) + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard frame.size != oldSize else { + layoutLoopCounter = 0 + return + } + + updateContentSizeIfNeeded() + } + + private func updateContentSizeIfNeeded() { + // Ensure we don't get stuck in an infinite layout loop somehow + guard layoutLoopCounter < 5 else { return } + + // Update the contentSize of the scrollView to match the size of the label + scrollView.contentSize = label.sizeThatFits( + CGSize(width: scrollView.bounds.width, height: CGFloat.greatestFiniteMagnitude) + ) + + // If scrolling is enabled and the maximum height we want to show is smaller than the scrollable height + // then we need to fix the height of the scroll view to our desired maximum, other + let maxCalculatedHeight: CGFloat = (label.font.lineHeight * CGFloat(maxNumberOfLinesWhenScrolling)) + + switch (canScroll, maxCalculatedHeight <= scrollView.contentSize.height) { + case (false, _), (true, false): + scrollViewHeightAnchor.isActive = false + labelHeightAnchor.isActive = true + + case (true, true): + labelHeightAnchor.isActive = false + scrollViewHeightAnchor.constant = maxCalculatedHeight + scrollViewHeightAnchor.isActive = true + } + + oldSize = frame.size + + // The view should have the same height as the scrollView, if it doesn't then we might need to relayout + // again to ensure the frame size is correct + guard + scrollView.frame.size.height < CGFloat.leastNonzeroMagnitude || + abs(frame.size.height - scrollView.frame.size.height) > CGFloat.leastNonzeroMagnitude + else { return } + + layoutLoopCounter += 1 + setNeedsLayout() + layoutIfNeeded() + } +}