diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf83c94..7c3c6353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ # Changelog -### 1.46.0 +### 1.50.0 - **Added**: `BaseModalViewController` implementing `PanModalPresentable` with additional functionality +- **Added**: `BaseModalWrapperViewController` for wrapping `UIViewController`s with `BaseModalViewController` functionality - **Updated**: Helper methods for `WrappedLayout` and `UIEdgeInsets` ### 1.45.0 diff --git a/Package.swift b/Package.swift index 4470d532..c0b55ed1 100644 --- a/Package.swift +++ b/Package.swift @@ -65,7 +65,7 @@ let package = Package( .target(name: "TIBottomSheet", dependencies: ["TIUIElements", "TIUIKitCore", "TISwiftUtils"], path: "TIBottomSheet/Sources", - exclude: ["TIBottomSheet.app"], + exclude: ["../TIBottomSheet.app"], plugins: [.plugin(name: "TISwiftLintPlugin")]), // MARK: - SwiftUI diff --git a/TIBottomSheet/Sources/BottomSheet/BaseModalWrapperViewController.swift b/TIBottomSheet/Sources/BottomSheet/BaseModalWrapperViewController.swift new file mode 100644 index 00000000..5992d04b --- /dev/null +++ b/TIBottomSheet/Sources/BottomSheet/BaseModalWrapperViewController.swift @@ -0,0 +1,42 @@ +// +// Copyright (c) 2023 Touch Instinct +// +// 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. +// + +import UIKit + +open class BaseModalWrapperViewController: BaseModalViewController +where ContentViewController: UIViewController { + + private(set) public lazy var contentViewController = createContentViewController() + + + // MARK: - BaseModalViewController + + open override func createContentView() -> UIView { + contentViewController.view + } + + // MARK: - Open methods + + open func createContentViewController() -> ContentViewController { + ContentViewController() + } +} diff --git a/TIBottomSheet/TIBottomSheet.app/.gitignore b/TIBottomSheet/TIBottomSheet.app/.gitignore index 44a647a9..2784c2d1 100644 --- a/TIBottomSheet/TIBottomSheet.app/.gitignore +++ b/TIBottomSheet/TIBottomSheet.app/.gitignore @@ -1,4 +1,3 @@ -# gitignore nef files **/build/ **/nef/ LICENSE diff --git a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Pages/TIBottomSheet.xcplaygroundpage/Contents.swift b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Pages/TIBottomSheet.xcplaygroundpage/Contents.swift index 2329df12..99f12ee4 100644 --- a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Pages/TIBottomSheet.xcplaygroundpage/Contents.swift +++ b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Pages/TIBottomSheet.xcplaygroundpage/Contents.swift @@ -3,7 +3,7 @@ TIBottomSheet содержить базовую реализацию модального котроллера и немного видоизмененную библиотеку PanModal. - # Базовый контроллер + ## Базовый контроллер Для создания модального котроллера можно унаследоваться от `BaseModalViewController`. Данный клас принимает два generic типа: тип основного контента, тип контента футера. */ @@ -12,6 +12,28 @@ import UIKit class EmptyViewController: BaseModalViewController { } +/*: + ## Обертка вокруг существующего контроллера + + Может быть такое, что из уже существующего контроллера нужно сделать модальное окно. С этим может помочь обертка `BaseModalWrapperViewController`. Данный контроллер является наследником BaseModalViewController, что позволяет его настраивать так же, как и базовый модальный котроллер + */ + +import TIUIKitCore + +final class OldMassiveViewController: BaseInitializableViewController { + // some implementation +} + +typealias ModalOldMassiveViewController = BaseModalWrapperViewController + +class PresentingViewController: BaseInitializableViewController { + // some implementation + + @objc private func onButtonTapped() { + presentPanModal(ModalOldMassiveViewController()) + } +} + /*: ## Контент модального контроллера diff --git a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Sources/NefTest.swift b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Sources/NefTest.swift new file mode 100644 index 00000000..fe15078e --- /dev/null +++ b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/Sources/NefTest.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest + +public extension Nef { + + static func run(testCase class: T.Type) { + startTestObserver() + T.defaultTestSuite.run() + } + + static private func startTestObserver() { + _ = testObserverInstalled + } + + static private var testObserverInstalled = { () -> NefTestFailObserver in + let testObserver = NefTestFailObserver() + XCTestObservationCenter.shared.addTestObserver(testObserver) + return testObserver + }() +} + +// MARK: enrich the output for XCTest +fileprivate class NefTestFailObserver: NSObject, XCTestObservation { + + private var numberOfFailedTests = 0 + + func testSuiteWillStart(_ testSuite: XCTestSuite) { + numberOfFailedTests = 0 + } + + func testSuiteDidFinish(_ testSuite: XCTestSuite) { + if numberOfFailedTests > 0 { + print("💢 Test Suite '\(testSuite.name)' finished with \(numberOfFailedTests) failed \(numberOfFailedTests > 1 ? "tests" : "test").") + } else { + print("🔅 Test Suite '\(testSuite.name)' finished successfully.") + } + } + + func testCase(_ testCase: XCTestCase, + didFailWithDescription description: String, + inFile filePath: String?, + atLine lineNumber: Int) { + + numberOfFailedTests += 1 + print("❗️Test Fail '\(testCase.name)':\(UInt(lineNumber)): \(description.description)") + } +} diff --git a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/contents.xcplayground b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/contents.xcplayground index 3debe4b3..00daa653 100644 --- a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/contents.xcplayground +++ b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.playground/contents.xcplayground @@ -1,2 +1,2 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcodeproj/project.pbxproj b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcodeproj/project.pbxproj index 180386ce..c7711f67 100644 --- a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcodeproj/project.pbxproj +++ b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcodeproj/project.pbxproj @@ -6,9 +6,16 @@ objectVersion = 50; objects = { +/* Begin PBXBuildFile section */ + 83C08983988F66570478C40D /* Pods_TIBottomSheet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8EF3488B86B483233C2CC631 /* Pods_TIBottomSheet.framework */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ + 7B6955D74676A5427AC42234 /* Pods-TIBottomSheet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TIBottomSheet.debug.xcconfig"; path = "Target Support Files/Pods-TIBottomSheet/Pods-TIBottomSheet.debug.xcconfig"; sourceTree = ""; }; 8BACBE8322576CAD00266845 /* TIBottomSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TIBottomSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8BACBE8622576CAD00266845 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8EF3488B86B483233C2CC631 /* Pods_TIBottomSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TIBottomSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AA57D8210790AD14BCC54A7E /* Pods-TIBottomSheet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TIBottomSheet.release.xcconfig"; path = "Target Support Files/Pods-TIBottomSheet/Pods-TIBottomSheet.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -16,17 +23,38 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 83C08983988F66570478C40D /* Pods_TIBottomSheet.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 11F06D2789C6CF40767861CF /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8EF3488B86B483233C2CC631 /* Pods_TIBottomSheet.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 1F7782E3A7AD7291B7C09F56 /* Pods */ = { + isa = PBXGroup; + children = ( + 7B6955D74676A5427AC42234 /* Pods-TIBottomSheet.debug.xcconfig */, + AA57D8210790AD14BCC54A7E /* Pods-TIBottomSheet.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 8B39A26221D40F8700DE2643 = { isa = PBXGroup; children = ( 8BACBE8422576CAD00266845 /* TIBottomSheet */, 8B39A26C21D40F8700DE2643 /* Products */, + 1F7782E3A7AD7291B7C09F56 /* Pods */, + 11F06D2789C6CF40767861CF /* Frameworks */, ); sourceTree = ""; }; @@ -63,6 +91,7 @@ isa = PBXNativeTarget; buildConfigurationList = 8BACBE8A22576CAD00266845 /* Build configuration list for PBXNativeTarget "TIBottomSheet" */; buildPhases = ( + 4E98D4C60DCD00EB801E579E /* [CP] Check Pods Manifest.lock */, 8BACBE7E22576CAD00266845 /* Headers */, 8BACBE7F22576CAD00266845 /* Sources */, 8BACBE8022576CAD00266845 /* Frameworks */, @@ -120,6 +149,31 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 4E98D4C60DCD00EB801E579E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-TIBottomSheet-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 8BACBE7F22576CAD00266845 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -254,6 +308,7 @@ }; 8BACBE8822576CAD00266845 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7B6955D74676A5427AC42234 /* Pods-TIBottomSheet.debug.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; @@ -284,6 +339,7 @@ }; 8BACBE8922576CAD00266845 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA57D8210790AD14BCC54A7E /* Pods-TIBottomSheet.release.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Manual; diff --git a/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcworkspace/contents.xcworkspacedata b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..6389ec4c --- /dev/null +++ b/TIBottomSheet/TIBottomSheet.app/Contents/MacOS/TIBottomSheet.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift index 5ad77c0e..82c65412 100644 --- a/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift +++ b/TIUIElements/Sources/Views/Skeletons/Configuration/BaseViewSkeletonsConfiguration.swift @@ -20,6 +20,7 @@ // THE SOFTWARE. // +import TIUIKitCore import UIKit open class BaseViewSkeletonsConfiguration { diff --git a/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift b/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift index ef68b06a..4819e742 100644 --- a/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift +++ b/TIUIElements/Sources/Views/Skeletons/Configuration/TextSkeletonsConfiguration.swift @@ -21,6 +21,7 @@ // import TISwiftUtils +import TIUIKitCore import UIKit open class TextSkeletonsConfiguration: BaseViewSkeletonsConfiguration { diff --git a/TIUIElements/Sources/Wrappers/Containers/ScrollViewWrapper.swift b/TIUIElements/Sources/Wrappers/Containers/ScrollViewWrapper.swift index d9672ca4..3edaf125 100644 --- a/TIUIElements/Sources/Wrappers/Containers/ScrollViewWrapper.swift +++ b/TIUIElements/Sources/Wrappers/Containers/ScrollViewWrapper.swift @@ -20,7 +20,6 @@ // THE SOFTWARE. // -import TIUIElements import UIKit public final class ScrollViewWrapper: UIScrollView { diff --git a/TIUIElements/TIUIElements.podspec b/TIUIElements/TIUIElements.podspec index efc65250..8879a41a 100644 --- a/TIUIElements/TIUIElements.podspec +++ b/TIUIElements/TIUIElements.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.ios.deployment_target = '11.0' s.swift_versions = ['5.7'] - sources = '/Sources/**/*' + sources = 'Sources/**/*' if ENV["DEVELOPMENT_INSTALL"] # installing using :path => s.source_files = sources s.exclude_files = s.name + '.app' diff --git a/docs/tibottomsheet/tibottomsheet.md b/docs/tibottomsheet/tibottomsheet.md new file mode 100644 index 00000000..f04a13db --- /dev/null +++ b/docs/tibottomsheet/tibottomsheet.md @@ -0,0 +1,120 @@ + +# TIBottomSheet + + TIBottomSheet содержить базовую реализацию модального котроллера и немного видоизмененную библиотеку PanModal. + +## Базовый контроллер + + Для создания модального котроллера можно унаследоваться от `BaseModalViewController`. Данный клас принимает два generic типа: тип основного контента, тип контента футера. + +```swift +import TIBottomSheet +import UIKit + +class EmptyViewController: BaseModalViewController { } +``` + +## Обертка вокруг существующего контроллера + + Может быть такое, что из уже существующего контроллера нужно сделать модальное окно. С этим может помочь обертка `BaseModalWrapperViewController`. Данный контроллер является наследником BaseModalViewController, что позволяет его настраивать так же, как и базовый модальный котроллер + +```swift +import TIUIKitCore + +final class OldMassiveViewController: BaseInitializableViewController { + // some implementation +} + +typealias ModalOldMassiveViewController = BaseModalWrapperViewController + +class PresentingViewController: BaseInitializableViewController { + // some implementation + + @objc private func onButtonTapped() { + presentPanModal(ModalOldMassiveViewController()) + } +} +``` + +## Контент модального контроллера + + Модальный котроллер может содержать следующие элементы: `DragView`, `HeaderView`, `FooterView`. Каждый из них является опциональным и без дополнительных настроек не будет показываться. + + DragView - небольшая view, за которую пользователь "держит" модальный контроллер + HeaderView - контейнер, содержащий в себе кнопки назад/закрыть или какие-то другие элементы управления + FooterView - view, располагающаяся внизу контроллера, поверх всего контента (модальный контроллер уже настроен так, чтобы при скролле в самый низ, футер не перекрывал последнюю ячейку) + + Для настройки каждого у котроллера есть свойство `viewControllerAppearance`. Через него будет настраиваться весь контроллер. Однако стоит заметить, что котроллер не будет настраивать передаваимую вью, содержащую основной контент. Стандартно котроллер будет пытаться расположить контент так, чтобы он заполнил все пространство. + + Вот пример настройки внешнего вида так, чтобы был видет dragView и headerView с левой кнопкой: + +```swift +class CustomViewController: BaseModalViewController { + override var viewControllerAppearance: BaseAppearance { + let appearance = super.viewControllerAppearance + + appearance.dragViewState = .presented(.defaultAppearance) + appearance.headerViewState = .presented(.make { + $0.backgroundColor = .white + $0.contentViewState = .buttonLeft(.init(titles: [.normal: "Close"], + appearance: [.normal: .init(backgroundColor: .white)])) + }) + + return appearance + } +} +``` + +## "Якори" контроллера + + Раньше для настройки высоты контроллера необходимо было пользоваться свойствами `longFormHeight`, `shortFormHeight`. В базовом контроллере можно лишь передать список точек на которых контроллер должен будет задержаться: + +```swift +class DetentsViewController: BaseModalViewController { + override var presentationDetents: [ModalViewPresentationDetent] { + [.headerOnly, .height(300), .maxHeight] + } +} +``` + + - headerOnly будет сам пытаться вычеслить высоту хедера и dragView, показывая только их + - height(_) будет показывать контроллер на переданной высоте + - maxHeight - вся высота экрана (до safeArea) + + В данный массив не рекомендуется передавать больше 3 значений, т.к. модальное окно все равно сможет занять только 3 положения на экране. + +## DimmedView + + Для контроля `DimmedView` (затемняющей view) есть отдельное свойство `dimmedViewType`. Это перечисление, содержащие следующие кейсы: + + - opaque: dimmedView не прозрачен и чем выше будет подниматься, тем больше будет затемнение. В shortFormHeight прозрачность равна 0.0, в longFormHeight - 1.0. + - transparent: dimmedView полностью прозрачен и будет пропускать все жесты на нижний (показывающий) контроллер + - transparentWithShadow(_) dimmedView полностью прозрачен, однако модальное окно будет отбразывать тень на нижний контроллер. Все жесты так же проходят + + > `UIViewShadow` получил статичное свойство для быстрой настройки тени котроллера + +```swift +class ShadowViewController: BaseModalViewController { + var dimmedViewType: DimmedView.AppearanceType { + .transparentWithShadow(.defaultModalViewShadow) + } +} +``` + +## Контроль закрытия + + `PanModalPresentable` не умеет в настройку закрытия контроллера, делая это самостоятельно через `dismiss(animated:completion:)`. Теперь можно настроить закрытие самостоятельно через свойства: `onTapToDismiss` и `onDragToDismiss`. + +# Взаимедействие с PanModal + + Если нет необходимости или возможности использовать `BaseModalViewController`, вы все так же можете пользоваться протоколом `PanModalRepresentable`. Вот список изменений протокола: + + - `DragView` больше не добавляется самостоятельно автоматически. Однако теперь к нему есть доступ, так что его даже можно как угодно настроить + - Открытие/закрытие модального окна теперь можно настроить с помощью свойств `onTapToDismiss` и `onDragToDismiss` + - Больше нет свойст отвечающих за разрешение закрыть модалку тапом или свайпом. Если какое-то из этих действий необходимо запретить, объявите соответствующее действие (`onTapToDismiss` и `onDragToDismiss`) с nil + - Можно настроить промежуточное состояние модального окна с `mediumFormHeight` + - `DimmedView` остается настраиваемым с помощью `dimmedViewType` свойства + + Через протокол `PanModalPresentable` у вас остается доступ к `presentationDetents`. Однако его установка никак не повлияет на настройку высоты и положений модального окна. + + > Для `BaseModalViewController` все свойства из `PanModalPresentable` все также работают, т.е. вы можете их переопределять, добавлять и изменять по необходимости.