feat: added bodal wrapper view controller

This commit is contained in:
Nikita Semenov 2023-07-03 01:47:52 +03:00
parent 86fddafcdf
commit 808d40eca5
14 changed files with 306 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -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<ContentViewController>: BaseModalViewController<UIView, UIView>
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()
}
}

View File

@ -1,4 +1,3 @@
# gitignore nef files
**/build/
**/nef/
LICENSE

View File

@ -3,7 +3,7 @@
TIBottomSheet содержить базовую реализацию модального котроллера и немного видоизмененную библиотеку PanModal.
# Базовый контроллер
## Базовый контроллер
Для создания модального котроллера можно унаследоваться от `BaseModalViewController`. Данный клас принимает два generic типа: тип основного контента, тип контента футера.
*/
@ -12,6 +12,28 @@ import UIKit
class EmptyViewController: BaseModalViewController<UIView, UIView> { }
/*:
## Обертка вокруг существующего контроллера
Может быть такое, что из уже существующего контроллера нужно сделать модальное окно. С этим может помочь обертка `BaseModalWrapperViewController`. Данный контроллер является наследником BaseModalViewController, что позволяет его настраивать так же, как и базовый модальный котроллер
*/
import TIUIKitCore
final class OldMassiveViewController: BaseInitializableViewController {
// some implementation
}
typealias ModalOldMassiveViewController = BaseModalWrapperViewController<OldMassiveViewController>
class PresentingViewController: BaseInitializableViewController {
// some implementation
@objc private func onButtonTapped() {
presentPanModal(ModalOldMassiveViewController())
}
}
/*:
## Контент модального контроллера

View File

@ -0,0 +1,47 @@
import Foundation
import XCTest
public extension Nef {
static func run<T: XCTestCase>(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)")
}
}

View File

@ -1,2 +1,2 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='6.0' target-platform='ios' display-mode='raw' buildActiveScheme='true' executeOnSourceChanges='true'/>
<playground version='6.0' target-platform='ios' display-mode='raw' buildActiveScheme='true'/>

View File

@ -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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* 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 = "<group>";
};
1F7782E3A7AD7291B7C09F56 /* Pods */ = {
isa = PBXGroup;
children = (
7B6955D74676A5427AC42234 /* Pods-TIBottomSheet.debug.xcconfig */,
AA57D8210790AD14BCC54A7E /* Pods-TIBottomSheet.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
8B39A26221D40F8700DE2643 = {
isa = PBXGroup;
children = (
8BACBE8422576CAD00266845 /* TIBottomSheet */,
8B39A26C21D40F8700DE2643 /* Products */,
1F7782E3A7AD7291B7C09F56 /* Pods */,
11F06D2789C6CF40767861CF /* Frameworks */,
);
sourceTree = "<group>";
};
@ -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;

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef location = "group:TIBottomSheet.playground"></FileRef>
<FileRef
location = "group:TIBottomSheet.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@ -20,6 +20,7 @@
// THE SOFTWARE.
//
import TIUIKitCore
import UIKit
open class BaseViewSkeletonsConfiguration {

View File

@ -21,6 +21,7 @@
//
import TISwiftUtils
import TIUIKitCore
import UIKit
open class TextSkeletonsConfiguration: BaseViewSkeletonsConfiguration {

View File

@ -20,7 +20,6 @@
// THE SOFTWARE.
//
import TIUIElements
import UIKit
public final class ScrollViewWrapper<ContentView: UIView>: UIScrollView {

View File

@ -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'

View File

@ -0,0 +1,120 @@
# TIBottomSheet
TIBottomSheet содержить базовую реализацию модального котроллера и немного видоизмененную библиотеку PanModal.
## Базовый контроллер
Для создания модального котроллера можно унаследоваться от `BaseModalViewController`. Данный клас принимает два generic типа: тип основного контента, тип контента футера.
```swift
import TIBottomSheet
import UIKit
class EmptyViewController: BaseModalViewController<UIView, UIView> { }
```
## Обертка вокруг существующего контроллера
Может быть такое, что из уже существующего контроллера нужно сделать модальное окно. С этим может помочь обертка `BaseModalWrapperViewController`. Данный контроллер является наследником BaseModalViewController, что позволяет его настраивать так же, как и базовый модальный котроллер
```swift
import TIUIKitCore
final class OldMassiveViewController: BaseInitializableViewController {
// some implementation
}
typealias ModalOldMassiveViewController = BaseModalWrapperViewController<OldMassiveViewController>
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<UIView, UIView> {
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<UIView, UIView> {
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<UIView, UIView> {
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` все также работают, т.е. вы можете их переопределять, добавлять и изменять по необходимости.